import fetch from 'node-fetch';
import { chromium } from 'playwright';
export interface SearchResult {
file: string;
line: number;
content: string;
url: string;
type?: string;
}
export interface XRefResult {
signature: string;
definition?: SearchResult;
declaration?: SearchResult;
references: SearchResult[];
overrides: SearchResult[];
calls: SearchResult[];
}
export class ChromiumSearchError extends Error {
constructor(message: string, public cause?: Error) {
super(message);
this.name = 'ChromiumSearchError';
}
}
export class GerritAPIError extends Error {
constructor(message: string, public statusCode?: number, public cause?: Error) {
super(message);
this.name = 'GerritAPIError';
}
}
export interface SearchCodeParams {
query: string;
caseSensitive?: boolean;
language?: string;
filePattern?: string;
searchType?: 'content' | 'function' | 'class' | 'symbol' | 'comment';
excludeComments?: boolean;
limit?: number;
}
export interface GetFileParams {
filePath: string;
lineStart?: number;
lineEnd?: number;
}
export interface GetGerritCLCommentsParams {
clNumber: string;
patchset?: number;
includeResolved?: boolean;
}
export interface GetGerritCLDiffParams {
clNumber: string;
patchset?: number;
filePath?: string;
}
export interface GetGerritPatchsetFileParams {
clNumber: string;
filePath: string;
patchset?: number;
}
export interface SearchCommitsParams {
query: string;
author?: string;
since?: string;
until?: string;
limit?: number;
}
// === REVIEWER SUGGESTION TYPES ===
export interface ParsedOwnersFile {
path: string;
directOwners: string[];
perFileRules: Array<{
pattern: string;
owners: string[];
}>;
fileIncludes: string[];
includeDirectives: string[];
noParent: boolean;
hasWildcard: boolean;
}
export interface ReviewerCandidate {
email: string;
coverageScore: number;
activityScore: number;
combinedScore: number;
canReviewFiles: string[];
recentCommits: number;
lastCommitDate: string | null;
}
export interface ReviewerSuggestion {
clNumber: string;
subject: string;
changedFiles: string[];
suggestedReviewers: ReviewerCandidate[];
optimalSet: ReviewerCandidate[];
uncoveredFiles: string[];
analysisDetails: {
filesAnalyzed: number;
ownersFilesFetched: number;
commitHistoryFetched: number;
activityMonths: number;
};
}
export interface SuggestReviewersParams {
clNumber: string;
patchset?: number;
maxReviewers?: number;
activityMonths?: number;
excludeReviewers?: string[];
fast?: boolean; // Skip activity analysis for speed
}
export class ChromiumAPI {
private apiKey: string;
private cache = new Map<string, any>();
private debugMode = false;
constructor(apiKey?: string) {
this.apiKey = apiKey || process.env.CHROMIUM_SEARCH_API_KEY || 'AIzaSyCqPSptx9mClE5NU4cpfzr6cgdO_phV1lM';
}
setDebugMode(enabled: boolean): void {
this.debugMode = enabled;
}
private debug(...args: any[]): void {
if (this.debugMode) {
console.log(...args);
}
}
async searchCode(params: SearchCodeParams): Promise<SearchResult[]> {
const {
query,
caseSensitive = false,
language,
filePattern,
searchType,
excludeComments = false,
limit = 20
} = params;
// Build the enhanced search query using Code Search syntax
let searchQuery = query;
// Add case sensitivity if requested
if (caseSensitive) {
searchQuery = `case:yes ${searchQuery}`;
}
// Add language filter if specified
if (language) {
searchQuery = `lang:${language} ${searchQuery}`;
}
// Add file pattern filter if specified
if (filePattern) {
searchQuery = `file:${filePattern} ${searchQuery}`;
}
// Add search type filter if specified
if (searchType) {
switch (searchType) {
case 'content':
searchQuery = `content:${query}`;
break;
case 'function':
searchQuery = `function:${query}`;
break;
case 'class':
searchQuery = `class:${query}`;
break;
case 'symbol':
searchQuery = `symbol:${query}`;
break;
case 'comment':
searchQuery = `comment:${query}`;
break;
}
// Apply other filters to the type-specific query
if (caseSensitive) searchQuery = `case:yes ${searchQuery}`;
if (language) searchQuery = `lang:${language} ${searchQuery}`;
if (filePattern) searchQuery = `file:${filePattern} ${searchQuery}`;
}
// Add usage filter to exclude comments if requested
if (excludeComments && !searchType) {
searchQuery = `usage:${query}`;
if (caseSensitive) searchQuery = `case:yes ${searchQuery}`;
if (language) searchQuery = `lang:${language} ${searchQuery}`;
if (filePattern) searchQuery = `file:${filePattern} ${searchQuery}`;
}
try {
const response = await this.callChromiumSearchAPI(searchQuery, limit);
return this.parseChromiumAPIResponse(response);
} catch (error: any) {
throw new ChromiumSearchError(`Search failed: ${error.message}`, error);
}
}
async findSymbol(symbol: string, filePath?: string): Promise<{
symbol: string;
symbolResults: SearchResult[];
classResults: SearchResult[];
functionResults: SearchResult[];
usageResults: SearchResult[];
estimatedUsageCount?: number;
}> {
try {
// Search for symbol definitions using Code Search syntax
const symbolResults = await this.callChromiumSearchAPI(`symbol:${symbol}`, 10);
const symbolParsed = this.parseChromiumAPIResponse(symbolResults);
// Search for class definitions
const classResults = await this.callChromiumSearchAPI(`class:${symbol}`, 5);
const classParsed = this.parseChromiumAPIResponse(classResults);
// Search for function definitions
const functionResults = await this.callChromiumSearchAPI(`function:${symbol}`, 5);
const functionParsed = this.parseChromiumAPIResponse(functionResults);
// Search for general usage in content (excluding comments)
const usageResults = await this.callChromiumSearchAPI(`usage:${symbol}`, 10);
const usageParsed = this.parseChromiumAPIResponse(usageResults);
return {
symbol,
symbolResults: symbolParsed,
classResults: classParsed,
functionResults: functionParsed,
usageResults: usageParsed,
estimatedUsageCount: usageResults.estimatedResultCount
};
} catch (error: any) {
throw new ChromiumSearchError(`Symbol lookup failed: ${error.message}`, error);
}
}
async getFile(params: GetFileParams): Promise<{
filePath: string;
content: string;
totalLines: number;
displayedLines: number;
lineStart?: number;
lineEnd?: number;
browserUrl: string;
source?: string;
githubUrl?: string;
webrtcUrl?: string;
}> {
const { filePath, lineStart, lineEnd } = params;
try {
// Check if this is a submodule file
if (filePath.startsWith('v8/')) {
return await this.getV8FileViaGitHub(filePath, lineStart, lineEnd);
}
if (filePath.startsWith('third_party/webrtc/')) {
return await this.getWebRTCFileViaGitiles(filePath, lineStart, lineEnd);
}
// Fetch from Gitiles API
const gitileUrl = `https://chromium.googlesource.com/chromium/src/+/main/${filePath}?format=TEXT`;
const response = await fetch(gitileUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch file: HTTP ${response.status}`);
}
// The response is base64 encoded
const base64Content = await response.text();
const fileContent = Buffer.from(base64Content, 'base64').toString('utf-8');
// Split into lines for line number processing
const lines = fileContent.split('\n');
let displayLines = lines;
let startLine = 1;
// Apply line range if specified
if (lineStart) {
const start = Math.max(1, lineStart) - 1; // Convert to 0-based
const end = lineEnd ? Math.min(lines.length, lineEnd) : lines.length;
displayLines = lines.slice(start, end);
startLine = start + 1;
}
// Format content with line numbers
const numberedLines = displayLines.map((line, index) => {
const lineNum = (startLine + index).toString().padStart(4, ' ');
return `${lineNum} ${line}`;
}).join('\n');
// Create browser URL for reference
let browserUrl = `https://source.chromium.org/chromium/chromium/src/+/main:${filePath}`;
if (lineStart) {
browserUrl += `;l=${lineStart}`;
if (lineEnd) {
browserUrl += `-${lineEnd}`;
}
}
return {
filePath,
content: numberedLines,
totalLines: lines.length,
displayedLines: displayLines.length,
lineStart,
lineEnd,
browserUrl
};
} catch (error: any) {
throw new ChromiumSearchError(`File fetch failed: ${error.message}`, error);
}
}
async getGerritCLStatus(clNumber: string): Promise<any> {
try {
// Extract CL number from URL if needed
const clNum = this.extractCLNumber(clNumber);
const gerritUrl = `https://chromium-review.googlesource.com/changes/${clNum}?o=CURRENT_REVISION&o=DETAILED_ACCOUNTS&o=SUBMIT_REQUIREMENTS&o=CURRENT_COMMIT`;
const response = await fetch(gerritUrl);
if (!response.ok) {
throw new GerritAPIError(`Failed to fetch CL status: ${response.status}`, response.status);
}
const text = await response.text();
// Remove XSSI protection prefix
const jsonText = text.replace(/^\)\]\}'\n/, '');
return JSON.parse(jsonText);
} catch (error: any) {
throw new GerritAPIError(`Gerrit API error: ${error.message}`, undefined, error);
}
}
async getGerritCLComments(params: GetGerritCLCommentsParams): Promise<any> {
try {
const clNum = this.extractCLNumber(params.clNumber);
const gerritUrl = `https://chromium-review.googlesource.com/changes/${clNum}/comments`;
const response = await fetch(gerritUrl);
if (!response.ok) {
throw new GerritAPIError(`Failed to fetch CL comments: ${response.status}`, response.status);
}
const text = await response.text();
const jsonText = text.replace(/^\)\]\}'\n/, '');
return JSON.parse(jsonText);
} catch (error: any) {
throw new GerritAPIError(`Gerrit comments API error: ${error.message}`, undefined, error);
}
}
async getGerritCLDiff(params: GetGerritCLDiffParams): Promise<any> {
const clId = this.extractCLNumber(params.clNumber);
try {
// First get CL details to know current patchset if not specified
const clDetailsUrl = `https://chromium-review.googlesource.com/changes/?q=change:${clId}&o=CURRENT_REVISION`;
const clResponse = await fetch(clDetailsUrl, {
headers: {
'Accept': 'application/json',
},
});
if (!clResponse.ok) {
throw new Error(`Failed to fetch CL details: ${clResponse.status}`);
}
const responseText = await clResponse.text();
const jsonText = responseText.replace(/^\)\]\}'\n/, '');
const clData = JSON.parse(jsonText);
if (!clData || clData.length === 0) {
throw new Error(`CL ${clId} not found`);
}
const cl = clData[0];
const targetPatchset = params.patchset || cl.current_revision_number || 1;
const revision = cl.revisions[cl.current_revision];
// Get the files list first to understand what changed
const filesUrl = `https://chromium-review.googlesource.com/changes/${clId}/revisions/${targetPatchset}/files`;
const filesResponse = await fetch(filesUrl, {
headers: {
'Accept': 'application/json',
},
});
if (!filesResponse.ok) {
throw new Error(`Failed to fetch files: ${filesResponse.status}`);
}
const filesText = await filesResponse.text();
const filesJsonText = filesText.replace(/^\)\]\}'\n/, '');
const filesData = JSON.parse(filesJsonText);
const changedFiles = Object.keys(filesData).filter(f => f !== '/COMMIT_MSG');
const result: any = {
clId,
subject: cl.subject,
patchset: targetPatchset,
author: cl.owner.name,
changedFiles,
filesData,
revision
};
if (params.filePath) {
// Get diff for specific file
if (!filesData[params.filePath]) {
result.error = `File ${params.filePath} not found in patchset ${targetPatchset}`;
return result;
}
const diffUrl = `https://chromium-review.googlesource.com/changes/${clId}/revisions/${targetPatchset}/files/${encodeURIComponent(params.filePath)}/diff?base=${targetPatchset-1}&context=ALL&intraline`;
const diffResponse = await fetch(diffUrl, {
headers: {
'Accept': 'application/json',
},
});
if (diffResponse.ok) {
const diffText = await diffResponse.text();
const diffJsonText = diffText.replace(/^\)\]\}'\n/, '');
result.diffData = JSON.parse(diffJsonText);
}
}
return result;
} catch (error: any) {
throw new GerritAPIError(`Failed to get CL diff: ${error.message}`, undefined, error);
}
}
async getGerritPatchsetFile(params: GetGerritPatchsetFileParams): Promise<any> {
const clId = this.extractCLNumber(params.clNumber);
try {
// First get CL details to know current patchset if not specified
const clDetailsUrl = `https://chromium-review.googlesource.com/changes/?q=change:${clId}&o=CURRENT_REVISION`;
const clResponse = await fetch(clDetailsUrl, {
headers: {
'Accept': 'application/json',
},
});
if (!clResponse.ok) {
throw new Error(`Failed to fetch CL details: ${clResponse.status}`);
}
const responseText = await clResponse.text();
const jsonText = responseText.replace(/^\)\]\}'\n/, '');
const clData = JSON.parse(jsonText);
if (!clData || clData.length === 0) {
throw new Error(`CL ${clId} not found`);
}
const cl = clData[0];
const targetPatchset = params.patchset || cl.current_revision_number || 1;
// Get the file content from the patchset
const fileUrl = `https://chromium-review.googlesource.com/changes/${clId}/revisions/${targetPatchset}/files/${encodeURIComponent(params.filePath)}/content`;
const fileResponse = await fetch(fileUrl, {
headers: {
'Accept': 'text/plain',
},
});
if (!fileResponse.ok) {
if (fileResponse.status === 404) {
throw new Error(`File ${params.filePath} not found in patchset ${targetPatchset}`);
}
throw new Error(`Failed to fetch file content: ${fileResponse.status}`);
}
// Gerrit returns base64 encoded content
const base64Content = await fileResponse.text();
const content = Buffer.from(base64Content, 'base64').toString('utf-8');
return {
clId,
subject: cl.subject,
patchset: targetPatchset,
author: cl.owner.name,
filePath: params.filePath,
content,
lines: content.split('\n').length
};
} catch (error: any) {
throw new GerritAPIError(`Failed to get file content: ${error.message}`, undefined, error);
}
}
async getGerritCLTrybotStatus(params: { clNumber: string; patchset?: number; failedOnly?: boolean }): Promise<any> {
const clId = this.extractCLNumber(params.clNumber);
try {
// Get CL messages to find LUCI Change Verifier URLs
const messagesUrl = `https://chromium-review.googlesource.com/changes/${clId}/messages`;
const response = await fetch(messagesUrl, {
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch messages: ${response.status}`);
}
const text = await response.text();
const jsonText = text.replace(/^\)\]\}'\n/, '');
const messages = JSON.parse(jsonText);
// Find LUCI Change Verifier URLs from messages
const luciUrls = this.extractLuciVerifierUrls(messages, params.patchset);
if (luciUrls.length === 0) {
return {
clId,
patchset: params.patchset || 'latest',
totalBots: 0,
failedBots: 0,
passedBots: 0,
runningBots: 0,
bots: [],
message: 'No LUCI runs found for this CL'
};
}
// Get detailed bot status from the most recent LUCI run
const latestLuciUrl = luciUrls[0];
const detailedBots = await this.fetchLuciRunDetails(latestLuciUrl.url);
// Filter by failed only if requested
const filteredBots = params.failedOnly
? detailedBots.filter(bot => bot.status === 'FAILED')
: detailedBots;
return {
clId,
patchset: latestLuciUrl.patchset,
runId: latestLuciUrl.runId,
luciUrl: latestLuciUrl.url,
totalBots: detailedBots.length,
failedBots: detailedBots.filter(bot => bot.status === 'FAILED').length,
passedBots: detailedBots.filter(bot => bot.status === 'PASSED').length,
runningBots: detailedBots.filter(bot => bot.status === 'RUNNING').length,
canceledBots: detailedBots.filter(bot => bot.status === 'CANCELED').length,
bots: filteredBots,
timestamp: latestLuciUrl.timestamp
};
} catch (error: any) {
throw new GerritAPIError(`Failed to get trybot status: ${error.message}`, undefined, error);
}
}
private extractLuciVerifierUrls(messages: any[], targetPatchset?: number): Array<{
url: string;
runId: string;
patchset: number;
timestamp: string;
}> {
const luciUrls: Array<{ url: string; runId: string; patchset: number; timestamp: string }> = [];
for (const msg of messages) {
// Skip if we want a specific patchset and this message is for a different one
if (targetPatchset && msg._revision_number && msg._revision_number !== targetPatchset) {
continue;
}
// Look for LUCI Change Verifier URLs in messages
if (msg.message) {
const luciMatch = msg.message.match(/Follow status at: (https:\/\/luci-change-verifier\.appspot\.com\/ui\/run\/chromium\/([^\/\s]+))/);
if (luciMatch) {
luciUrls.push({
url: luciMatch[1],
runId: luciMatch[2],
patchset: msg._revision_number || 0,
timestamp: msg.date
});
}
}
}
// Return most recent first
return luciUrls.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
private async fetchLuciRunDetails(luciUrl: string): Promise<any[]> {
try {
// Extract run ID from URL (works for both chromium and pdfium)
const runIdMatch = luciUrl.match(/\/run\/(?:chromium|pdfium)\/([^\/\s]+)/);
if (!runIdMatch) {
throw new Error('Could not extract run ID from LUCI URL');
}
const runId = runIdMatch[1];
// Fetch the LUCI page HTML directly
const response = await fetch(luciUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch LUCI page: ${response.status}`);
}
const html = await response.text();
this.debug(`[DEBUG] Fetched LUCI HTML, length: ${html.length}`);
// Parse the HTML to extract bot information
const bots = this.parseLuciHtmlImproved(html, luciUrl, runId);
this.debug(`[DEBUG] Found ${bots.length} bots from LUCI page`);
if (bots.length > 0) {
return bots;
}
// Fallback if parsing fails
return [{
name: 'LUCI Run',
status: 'UNKNOWN',
runId: runId,
luciUrl: luciUrl,
summary: 'Could not parse bot details - view at LUCI URL'
}];
} catch (error) {
this.debug(`[DEBUG] Failed to fetch LUCI details: ${error}`);
// Fallback to basic info if we can't fetch details
return [{
name: 'LUCI Run',
status: 'UNKNOWN',
luciUrl: luciUrl,
summary: 'View detailed bot status at LUCI URL'
}];
}
}
private parseLuciHtmlImproved(html: string, luciUrl: string, runId: string): any[] {
const bots: any[] = [];
const foundBots = new Set<string>();
try {
// Simple approach: Find all <a> elements with tryjob-chip classes
// This works for both Chromium and PDFium without hardcoding patterns
const tryjobPattern = /<a[^>]*class="[^"]*tryjob-chip[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gs;
let match;
while ((match = tryjobPattern.exec(html)) !== null) {
const fullMatch = match[0];
const href = match[1];
const innerText = match[2];
// Extract bot name from the inner text (trim whitespace)
const botName = innerText.trim().replace(/\s+/g, ' ');
if (botName && botName.length > 3 && !foundBots.has(botName)) {
foundBots.add(botName);
// Extract status from class attribute
let status = 'UNKNOWN';
if (fullMatch.includes('tryjob-chip passed')) {
status = 'PASSED';
} else if (fullMatch.includes('tryjob-chip failed')) {
status = 'FAILED';
} else if (fullMatch.includes('tryjob-chip running')) {
status = 'RUNNING';
} else if (fullMatch.includes('tryjob-chip canceled')) {
status = 'CANCELED';
}
// Construct the full CI build URL if we have a relative path
let buildUrl = href;
if (href && href.startsWith('/')) {
buildUrl = `https://ci.chromium.org${href}`;
}
bots.push({
name: botName,
status: status,
luciUrl: luciUrl,
runId: runId,
buildUrl: buildUrl,
summary: `${status.toLowerCase()}`
});
}
}
this.debug(`[DEBUG] Parsed ${bots.length} bots from HTML`);
this.debug(`[DEBUG] Status breakdown: ${JSON.stringify(this.getStatusCounts(bots))}`);
} catch (error) {
this.debug(`[DEBUG] Error parsing LUCI HTML: ${error}`);
}
return bots;
}
private extractBotStatus(html: string, matchIndex: number): string {
// Look for status indicators around this bot
const contextStart = Math.max(0, matchIndex - 500);
const contextEnd = Math.min(html.length, matchIndex + 500);
const context = html.slice(contextStart, contextEnd);
let status = 'UNKNOWN';
// Look for various status patterns in the surrounding context
if (this.checkForStatus(context, 'SUCCESS', 'PASSED', 'success')) {
status = 'PASSED';
} else if (this.checkForStatus(context, 'FAILURE', 'FAILED', 'failure', 'error')) {
status = 'FAILED';
} else if (this.checkForStatus(context, 'RUNNING', 'STARTED', 'running', 'pending')) {
status = 'RUNNING';
} else if (this.checkForStatus(context, 'CANCELED', 'CANCELLED', 'canceled')) {
status = 'CANCELED';
}
// Also check for CSS class patterns that indicate status
if (status === 'UNKNOWN') {
if (context.includes('class="green"') || context.includes('color:green') ||
context.includes('background-color:green') || context.includes('rgb(76, 175, 80)')) {
status = 'PASSED';
} else if (context.includes('class="red"') || context.includes('color:red') ||
context.includes('background-color:red') || context.includes('rgb(244, 67, 54)')) {
status = 'FAILED';
} else if (context.includes('class="yellow"') || context.includes('color:orange') ||
context.includes('background-color:yellow') || context.includes('rgb(255, 193, 7)')) {
status = 'RUNNING';
}
}
return status;
}
private checkForStatus(context: string, ...statusWords: string[]): boolean {
const lowerContext = context.toLowerCase();
return statusWords.some(word => lowerContext.includes(word.toLowerCase()));
}
private getStatusCounts(bots: any[]): { [key: string]: number } {
const counts: { [key: string]: number } = {};
bots.forEach(bot => {
counts[bot.status] = (counts[bot.status] || 0) + 1;
});
return counts;
}
async findOwners(filePath: string): Promise<{
filePath: string;
ownerFiles: Array<{
path: string;
content: string;
browserUrl: string;
}>;
}> {
try {
const ownerFiles = [];
const pathParts = filePath.split('/');
// Search up the directory tree for OWNERS files
for (let i = pathParts.length; i > 0; i--) {
const dirPath = pathParts.slice(0, i).join('/');
const ownersPath = dirPath ? `${dirPath}/OWNERS` : 'OWNERS';
try {
const result = await this.getFile({ filePath: ownersPath });
ownerFiles.push({
path: ownersPath,
content: result.content,
browserUrl: result.browserUrl
});
} catch (error) {
// OWNERS file doesn't exist at this level, continue up the tree
}
}
return {
filePath,
ownerFiles
};
} catch (error: any) {
throw new ChromiumSearchError(`Owners lookup failed: ${error.message}`, error);
}
}
async searchCommits(params: SearchCommitsParams): Promise<any> {
try {
let gitileUrl = `https://chromium.googlesource.com/chromium/src/+log/?format=JSON&n=${params.limit || 20}`;
if (params.since) {
gitileUrl += `&since=${params.since}`;
}
if (params.until) {
gitileUrl += `&until=${params.until}`;
}
if (params.author) {
gitileUrl += `&author=${encodeURIComponent(params.author)}`;
}
const response = await fetch(gitileUrl);
if (!response.ok) {
throw new Error(`Failed to fetch commits: HTTP ${response.status}`);
}
const text = await response.text();
const jsonText = text.replace(/^\)\]\}'\n/, '');
const result = JSON.parse(jsonText);
// Filter by query if provided
if (params.query) {
const query = params.query.toLowerCase();
result.log = result.log.filter((commit: any) =>
commit.message.toLowerCase().includes(query) ||
commit.author.name.toLowerCase().includes(query) ||
commit.author.email.toLowerCase().includes(query)
);
}
return result;
} catch (error: any) {
throw new ChromiumSearchError(`Commit search failed: ${error.message}`, error);
}
}
async getIssue(issueId: string): Promise<any> {
try {
const issueNum = this.extractIssueId(issueId);
const issueUrl = `https://issues.chromium.org/issues/${issueNum}`;
// Try direct API approach first (much faster than Playwright)
try {
const directApiResult = await this.getIssueDirectAPI(issueNum);
if (directApiResult && (directApiResult.comments?.length > 0 || directApiResult.description?.length > 20)) {
return {
issueId: issueNum,
browserUrl: issueUrl,
...directApiResult,
extractionMethod: 'direct-api'
};
} else {
this.debug(`[DEBUG] Direct API returned insufficient data, falling back to browser`);
}
} catch (error) {
this.debug(`[DEBUG] Direct API failed, falling back to browser: ${error}`);
}
// First try HTTP-based extraction for basic info
let basicInfo = null;
try {
const response = await fetch(issueUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
});
if (response.ok) {
const html = await response.text();
const jspbMatch = html.match(/defrostedResourcesJspb\s*=\s*(\[.*?\]);/s);
if (jspbMatch) {
try {
const issueData = JSON.parse(jspbMatch[1]);
basicInfo = this.extractIssueInfo(issueData, issueNum);
} catch (e) {
// Continue to browser automation
}
}
}
} catch (e) {
// Continue to browser automation
}
// Use browser automation for comprehensive data extraction
const browserInfo = await this.extractIssueWithBrowser(issueUrl, issueNum);
// Merge basic info with browser-extracted info
const mergedInfo = {
...basicInfo,
...browserInfo,
// Prefer browser-extracted title if it's more meaningful
title: (browserInfo.title && browserInfo.title !== 'Unknown' && !browserInfo.title.includes('Issue '))
? browserInfo.title
: basicInfo?.title || browserInfo.title,
extractionMethod: 'browser-automation'
};
return {
issueId: issueNum,
browserUrl: issueUrl,
...mergedInfo
};
} catch (error: any) {
const browserUrl = `https://issues.chromium.org/issues/${this.extractIssueId(issueId)}`;
return {
issueId: this.extractIssueId(issueId),
browserUrl,
error: `Failed to fetch issue details: ${error.message}`,
message: 'Use the browser URL to view the issue manually.'
};
}
}
private async callChromiumSearchAPI(query: string, limit: number): Promise<any> {
const searchPayload = {
queryString: query,
searchOptions: {
enableDiagnostics: false,
exhaustive: false,
numberOfContextLines: 1,
pageSize: Math.min(limit, 25),
pageToken: "",
pathPrefix: "",
repositoryScope: {
root: {
ossProject: "chromium",
repositoryName: "chromium/src"
}
},
retrieveMultibranchResults: true,
savedQuery: "",
scoringModel: "",
showPersonalizedResults: false,
suppressGitLegacyResults: false
},
snippetOptions: {
minSnippetLinesPerFile: 10,
minSnippetLinesPerPage: 60,
numberOfContextLines: 1
}
};
// Generate a boundary for multipart request
const boundary = `batch${Date.now()}${Math.random().toString().substr(2)}`;
// Create the multipart body exactly like the working curl
const multipartBody = [
`--${boundary}`,
'Content-Type: application/http',
`Content-ID: <response-${boundary}+gapiRequest@googleapis.com>`,
'',
`POST /v1/contents/search?alt=json&key=${this.apiKey}`,
'sessionid: ' + Math.random().toString(36).substr(2, 12),
'actionid: ' + Math.random().toString(36).substr(2, 12),
'X-JavaScript-User-Agent: google-api-javascript-client/1.1.0',
'X-Requested-With: XMLHttpRequest',
'Content-Type: application/json',
'X-Goog-Encode-Response-If-Executable: base64',
'',
JSON.stringify(searchPayload),
`--${boundary}--`,
''
].join('\r\n');
const response = await fetch(`https://grimoireoss-pa.clients6.google.com/batch?%24ct=multipart%2Fmixed%3B%20boundary%3D${boundary}`, {
method: 'POST',
headers: {
'accept': '*/*',
'accept-language': 'en-US,en;q=0.9',
'cache-control': 'no-cache',
'content-type': 'text/plain; charset=UTF-8',
'origin': 'https://source.chromium.org',
'pragma': 'no-cache',
'referer': 'https://source.chromium.org/',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'cross-site',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'
},
body: multipartBody
});
if (!response.ok) {
throw new ChromiumSearchError(`API request failed: ${response.status} ${response.statusText}`);
}
const responseText = await response.text();
// Parse the multipart response to extract JSON
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new ChromiumSearchError('Could not parse API response');
}
const result = JSON.parse(jsonMatch[0]);
return result;
}
private parseChromiumAPIResponse(apiResponse: any): SearchResult[] {
const results: SearchResult[] = [];
if (!apiResponse.searchResults) {
return results;
}
for (const searchResult of apiResponse.searchResults) {
const fileResult = searchResult.fileSearchResult;
if (!fileResult) continue;
const filePath = fileResult.fileSpec.path;
for (const snippet of fileResult.snippets || []) {
const snippetLines = snippet.snippetLines || [];
// Group lines by snippet for better context
if (snippetLines.length > 0) {
// Find the primary match line (the one with ranges)
const matchLines = snippetLines.filter((line: any) => line.ranges && line.ranges.length > 0);
if (matchLines.length > 0) {
// Use the first match line as the primary result
const primaryMatch = matchLines[0];
const lineNumber = parseInt(primaryMatch.lineNumber) || 0;
const url = `https://source.chromium.org/chromium/chromium/src/+/main:${filePath};l=${lineNumber}`;
// Build context with all lines in this snippet
const contextLines = snippetLines.map((line: any) => {
const lineText = line.lineText || '';
const hasMatch = line.ranges && line.ranges.length > 0;
return hasMatch ? `➤ ${lineText}` : ` ${lineText}`;
}).join('\n');
results.push({
file: filePath,
line: lineNumber,
content: contextLines,
url: url,
});
}
}
}
}
return results;
}
private extractCLNumber(clInput: string): string {
// Extract CL number from URL or return as-is if already a number
const match = clInput.match(/\/(\d+)(?:\/|$)/);
return match ? match[1] : clInput;
}
private extractIssueId(issueInput: string): string {
// Extract issue ID from URL or return as-is if already a number
const match = issueInput.match(/\/issues\/(\d+)/);
return match ? match[1] : issueInput;
}
private getFileExtension(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const extensionMap: { [key: string]: string } = {
'cc': 'cpp',
'cpp': 'cpp',
'cxx': 'cpp',
'h': 'cpp',
'hpp': 'cpp',
'js': 'javascript',
'ts': 'typescript',
'py': 'python',
'java': 'java',
'go': 'go',
'rs': 'rust',
'sh': 'bash',
'md': 'markdown',
'json': 'json',
'xml': 'xml',
'html': 'html',
'css': 'css',
'yml': 'yaml',
'yaml': 'yaml',
};
return extensionMap[ext] || '';
}
private extractIssueInfo(issueData: any, issueId: string): any {
try {
// The structure can vary, so we need to search through it
let issueArray = null;
// Try different common structures
if (issueData?.[1]?.[0]) {
issueArray = issueData[1][0];
} else if (issueData?.[0]?.[1]?.[0]) {
issueArray = issueData[0][1][0];
} else if (Array.isArray(issueData)) {
// Search for the issue array in the nested structure
for (const item of issueData) {
if (Array.isArray(item)) {
for (const subItem of item) {
if (Array.isArray(subItem) && subItem.length > 5) {
issueArray = subItem;
break;
}
}
if (issueArray) break;
}
}
}
if (!issueArray) {
// Try to extract basic info from the raw data string
const dataStr = JSON.stringify(issueData);
const titleMatch = dataStr.match(/"([^"]{10,200})"/);
return {
title: titleMatch ? titleMatch[1] : 'Issue data found but structure unknown',
status: 'Unknown',
priority: 'Unknown',
type: 'Unknown',
severity: 'Unknown',
reporter: 'Unknown',
assignee: 'Unknown',
created: 'Unknown',
modified: 'Unknown',
relatedCLs: this.extractRelatedCLsFromString(dataStr)
};
}
// Extract basic issue information
const title = issueArray[1] || issueArray[0] || 'No title';
const status = this.getStatusText(issueArray[2]?.[0] || issueArray[2]);
const priority = this.getPriorityText(issueArray[3]?.[0] || issueArray[3]);
const type = this.getTypeText(issueArray[4]?.[0] || issueArray[4]);
const severity = this.getSeverityText(issueArray[5]?.[0] || issueArray[5]);
// Extract timestamps
const created = this.formatTimestamp(issueArray[8] || issueArray[6]);
const modified = this.formatTimestamp(issueArray[9] || issueArray[7]);
// Extract reporter and assignee
const reporter = this.extractUserInfo(issueArray[6] || issueArray[10]);
const assignee = this.extractUserInfo(issueArray[7] || issueArray[11]);
// Look for related CLs in the issue data
const relatedCLs = this.extractRelatedCLs(issueArray);
return {
title,
status,
priority,
type,
severity,
reporter,
assignee,
created,
modified,
relatedCLs
};
} catch (error) {
return {
title: 'Unknown',
status: 'Unknown',
error: `Failed to parse issue data: ${error instanceof Error ? error.message : String(error)}`
};
}
}
private getStatusText(status: number): string {
const statusMap: { [key: number]: string } = {
1: 'NEW',
2: 'ASSIGNED',
3: 'ACCEPTED',
4: 'FIXED',
5: 'VERIFIED',
6: 'INVALID',
7: 'WONTFIX',
8: 'DUPLICATE',
9: 'ARCHIVED'
};
return statusMap[status] || `Status ${status}`;
}
private getPriorityText(priority: number): string {
const priorityMap: { [key: number]: string } = {
0: 'P0',
1: 'P1',
2: 'P2',
3: 'P3',
4: 'P4'
};
return priorityMap[priority] || `Priority ${priority}`;
}
private getTypeText(type: number): string {
const typeMap: { [key: number]: string } = {
1: 'Bug',
2: 'Feature',
3: 'Task'
};
return typeMap[type] || `Type ${type}`;
}
private getSeverityText(severity: number): string {
const severityMap: { [key: number]: string } = {
0: 'S0',
1: 'S1',
2: 'S2',
3: 'S3',
4: 'S4'
};
return severityMap[severity] || `Severity ${severity}`;
}
private extractUserInfo(userArray: any): string {
if (!userArray || !Array.isArray(userArray)) {
return 'Unknown';
}
// User info is typically in the first element as an email
return userArray[0] || 'Unknown';
}
private formatTimestamp(timestampArray: any): string {
if (!timestampArray || !Array.isArray(timestampArray)) {
return 'Unknown';
}
// Timestamp format: [seconds, nanoseconds]
const seconds = timestampArray[0];
if (typeof seconds === 'number') {
return new Date(seconds * 1000).toISOString();
}
return 'Unknown';
}
private extractRelatedCLs(issueArray: any): string[] {
return this.extractRelatedCLsFromString(JSON.stringify(issueArray));
}
private extractRelatedCLsFromString(str: string): string[] {
const cls: string[] = [];
// Look through the data for CL references
const clMatches = str.match(/chromium-review\.googlesource\.com\/c\/chromium\/src\/\+\/(\d+)/g);
if (clMatches) {
clMatches.forEach(match => {
const clNumber = match.match(/\/(\d+)$/)?.[1];
if (clNumber && !cls.includes(clNumber)) {
cls.push(clNumber);
}
});
}
// Also look for simple CL number patterns
const clNumberMatches = str.match(/CL[\s\-\#]*(\d{6,})/gi);
if (clNumberMatches) {
clNumberMatches.forEach(match => {
const clNumber = match.match(/(\d{6,})/)?.[1];
if (clNumber && !cls.includes(clNumber)) {
cls.push(clNumber);
}
});
}
return cls;
}
private extractIssueFromHTML(html: string, issueId: string): any {
// Simple HTML extraction as fallback
let title = `Issue ${issueId}`;
let status = 'Unknown';
// Try to extract title from page title, avoiding common false positives
const titleMatch = html.match(/<title[^>]*>([^<]+)</i);
if (titleMatch) {
const rawTitle = titleMatch[1].replace(/\s*-\s*Chromium\s*$/i, '').trim();
// Filter out obvious data structure names
if (rawTitle &&
!rawTitle.includes('IssueFetchResponse') &&
!rawTitle.includes('undefined') &&
!rawTitle.includes('null') &&
rawTitle.length > 5) {
title = rawTitle;
}
}
// Try multiple approaches to extract meaningful data
// Look for metadata in script tags
const scriptMatches = html.match(/<script[^>]*>(.*?)<\/script>/gis);
if (scriptMatches) {
for (const script of scriptMatches) {
// Look for various data patterns
const summaryMatch = script.match(/"summary"[^"]*"([^"]{10,})/i);
if (summaryMatch && !summaryMatch[1].includes('b.IssueFetchResponse')) {
title = summaryMatch[1];
break;
}
const titleMatch = script.match(/"title"[^"]*"([^"]{10,})/i);
if (titleMatch && !titleMatch[1].includes('b.IssueFetchResponse')) {
title = titleMatch[1];
break;
}
}
}
// Try to extract status from common patterns
const statusMatches = [
/"state"[^"]*"([^"]+)"/i,
/"status"[^"]*"([^"]+)"/i,
/status[^a-zA-Z]*([A-Z][A-Za-z]+)/i,
/Status:\s*([A-Z][A-Za-z]+)/i
];
for (const pattern of statusMatches) {
const match = html.match(pattern);
if (match && match[1] !== 'Unknown') {
status = match[1];
break;
}
}
// Extract related CLs from HTML
const relatedCLs = this.extractRelatedCLsFromString(html);
return {
title,
status,
priority: 'Unknown',
type: 'Unknown',
severity: 'Unknown',
reporter: 'Unknown',
assignee: 'Unknown',
created: 'Unknown',
modified: 'Unknown',
relatedCLs,
note: 'Basic extraction from HTML - for full details use browser URL'
};
}
private async extractIssueWithBrowser(issueUrl: string, issueId: string): Promise<any> {
let browser = null;
try {
// Launch browser
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
});
const page = await browser.newPage({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
});
// Set up request interception to capture batch API calls
const capturedRequests: any[] = [];
const capturedResponses: any[] = [];
// Enable request interception
await page.route('**/*', async (route) => {
const request = route.request();
const url = request.url();
// Log all requests for debugging
if (url.includes('/batch') || url.includes('googleapis.com') || url.includes('issues.chromium.org')) {
this.debug(`[DEBUG] Intercepted request: ${request.method()} ${url}`);
capturedRequests.push({
url,
method: request.method(),
headers: request.headers(),
postData: request.postData()
});
}
// Continue with the request
await route.continue();
});
// Capture responses
page.on('response', async (response) => {
const url = response.url();
if (url.includes('/batch') || url.includes('googleapis.com')) {
this.debug(`[DEBUG] Captured response: ${response.status()} ${url}`);
try {
const responseText = await response.text();
capturedResponses.push({
url,
status: response.status(),
headers: response.headers(),
body: responseText
});
} catch (e) {
this.debug(`[DEBUG] Could not capture response body: ${e}`);
}
}
});
// Navigate to issue page
await page.goto(issueUrl, {
waitUntil: 'networkidle',
timeout: 30000
});
// Wait for page to load and batch requests to complete
await page.waitForTimeout(5000);
// Try to wait for some common elements that indicate the page has loaded
try {
await page.waitForSelector('body', { timeout: 5000 });
} catch (e) {
// Continue anyway
}
// Try to parse batch API responses first
const batchApiData = this.parseBatchAPIResponses(capturedResponses, issueId);
// Extract issue information using multiple strategies
const issueInfo = await page.evaluate((issueId: string) => {
const result = {
title: `Issue ${issueId}`,
status: 'Unknown',
priority: 'Unknown',
type: 'Unknown',
severity: 'Unknown',
reporter: 'Unknown',
assignee: 'Unknown',
description: '',
relatedCLs: [] as string[]
};
// Strategy 1: Try to find issue title in common selectors
const titleSelectors = [
'[data-testid="issue-title"]',
'.issue-title',
'h1',
'h2',
'[role="heading"]',
'.MdcTextField-Input',
'[aria-label*="title"]',
'[title]',
'.title',
'input[type="text"]',
'textarea'
];
for (const selector of titleSelectors) {
const element = (window as any).document.querySelector(selector);
if (element && element.textContent && element.textContent.trim().length > 5) {
const text = element.textContent.trim();
if (!text.includes('Issue ') && !text.includes('Unknown') && text.length > 10) {
result.title = text;
break;
}
}
}
// Strategy 2: Look for status indicators
const statusSelectors = [
'[data-testid="status"]',
'.status',
'.issue-status',
'[aria-label*="status"]'
];
for (const selector of statusSelectors) {
const element = (window as any).document.querySelector(selector);
if (element && element.textContent) {
const status = element.textContent.trim().toUpperCase();
if (['NEW', 'ASSIGNED', 'ACCEPTED', 'FIXED', 'VERIFIED', 'INVALID', 'WONTFIX', 'DUPLICATE'].includes(status)) {
result.status = status;
break;
}
}
}
// Strategy 3: Extract description content
const descriptionSelectors = [
'[data-testid="description"]',
'.issue-description',
'.description',
'[role="textbox"]',
'.ql-editor',
'.comment-content'
];
for (const selector of descriptionSelectors) {
const element = (window as any).document.querySelector(selector);
if (element && element.textContent && element.textContent.trim().length > 20) {
result.description = element.textContent.trim().substring(0, 500);
break;
}
}
// Strategy 4: Look for metadata in any visible text
const pageText = (window as any).document.body.textContent || '';
// Try to extract title from page title as fallback
const pageTitle = (window as any).document.title;
if (pageTitle && pageTitle.length > 5 && !pageTitle.includes('Chrome') && result.title.includes('Issue ')) {
result.title = pageTitle.replace(/\s*-.*$/, '').trim();
}
// Look for priority patterns
const priorityMatch = pageText.match(/P[0-4]/);
if (priorityMatch) {
result.priority = priorityMatch[0];
}
// Look for type patterns
const typeMatch = pageText.match(/Type:\s*(Bug|Feature|Task)/i);
if (typeMatch) {
result.type = typeMatch[1];
}
// Look for reporter/assignee patterns
const reporterMatch = pageText.match(/Reporter:\s*([^\n]+)/i);
if (reporterMatch) {
result.reporter = reporterMatch[1].trim();
}
const assigneeMatch = pageText.match(/Assignee:\s*([^\n]+)/i);
if (assigneeMatch) {
result.assignee = assigneeMatch[1].trim();
}
// Strategy 5: Extract CL references from page
const clMatches = pageText.match(/(?:CL|chromium-review\.googlesource\.com\/c\/chromium\/src\/\+\/)[\s#]*(\d{6,})/g);
if (clMatches) {
const cls = new Set<string>();
clMatches.forEach((match: string) => {
const clNumber = match.match(/(\d{6,})/)?.[1];
if (clNumber) {
cls.add(clNumber);
}
});
result.relatedCLs = Array.from(cls);
}
return result;
}, issueId);
// Merge batch API data with extracted data, preferring batch API data when available
const mergedData = {
...issueInfo,
...batchApiData,
batchApiDebug: {
requestsCaptured: capturedRequests.length,
responsesCaptured: capturedResponses.length,
batchRequestsFound: capturedRequests.filter(r => r.url.includes('/batch')).length,
batchResponsesFound: capturedResponses.filter(r => r.url.includes('/batch')).length
}
};
return mergedData;
} catch (error) {
return {
title: `Issue ${issueId}`,
status: 'Unknown',
error: `Browser extraction failed: ${error instanceof Error ? error.message : String(error)}`,
note: 'Browser automation failed - data may be incomplete'
};
} finally {
if (browser) {
await browser.close();
}
}
}
private parseBatchAPIResponses(responses: any[], issueId: string): any {
const result: any = {};
for (const response of responses) {
if (!response.body || (!response.url.includes('/batch') && !response.url.includes('/events'))) {
continue;
}
try {
this.debug(`[DEBUG] Parsing response from ${response.url}`);
let responseData = response.body;
// Remove the security prefix if present
if (responseData.startsWith(")]}'")){
responseData = responseData.substring(4).trim();
}
// Try to parse as JSON array
let batchData;
try {
batchData = JSON.parse(responseData);
} catch (e) {
this.debug(`[DEBUG] Could not parse response as JSON: ${e}`);
continue;
}
if (Array.isArray(batchData)) {
this.extractIssueDataFromBatchArray(batchData, result, issueId);
}
} catch (error) {
this.debug(`[DEBUG] Error parsing response: ${error}`);
}
}
this.debug(`[DEBUG] Final extracted batch data:`, result);
return result;
}
private async getIssueDirectAPI(issueId: string): Promise<any> {
const baseUrl = 'https://issues.chromium.org';
const headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Referer': `${baseUrl}/issues/${issueId}`
};
const result: any = {
title: undefined,
status: undefined,
priority: undefined,
type: undefined,
severity: undefined,
reporter: undefined,
assignee: undefined,
description: undefined,
comments: [],
relatedCLs: []
};
try {
// 1. Get issue summary/details
try {
const summaryResponse = await fetch(`${baseUrl}/action/issues/${issueId}/getSummary`, {
method: 'POST',
headers
});
if (summaryResponse.ok) {
const summaryText = await summaryResponse.text();
const summaryData = this.parseResponseData(summaryText);
if (summaryData) {
this.extractIssueDataFromBatchArray(summaryData, result, issueId);
}
}
} catch (e) {
this.debug(`[DEBUG] Summary API failed: ${e}`);
}
// 2. Get issue events (contains comments, status changes, etc.)
try {
const eventsResponse = await fetch(`${baseUrl}/action/issues/${issueId}/events?currentTrackerId=157`, {
headers
});
if (eventsResponse.ok) {
const eventsText = await eventsResponse.text();
this.debug(`[DEBUG] Events API successful, length: ${eventsText.length}`);
const eventsData = this.parseResponseData(eventsText);
if (eventsData) {
this.debug(`[DEBUG] Processing events data with ${eventsData.length} items`);
this.extractEventsData(eventsData, result, issueId);
}
}
} catch (e) {
this.debug(`[DEBUG] Events API failed: ${e}`);
}
// 3. Get comments from events (more reliable than batch API)
try {
// Try a simpler comments endpoint first
const commentsResponse = await fetch(`${baseUrl}/action/comments/batch`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json'
},
body: JSON.stringify([
["b.BatchGetIssueCommentsRequest", {
"issueId": parseInt(issueId),
"maxComments": 50
}]
])
});
if (commentsResponse.ok) {
const commentsText = await commentsResponse.text();
this.debug(`[DEBUG] Comments batch successful, length: ${commentsText.length}`);
const commentsData = this.parseResponseData(commentsText);
if (commentsData) {
this.debug(`[DEBUG] Found ${commentsData.length} top-level items in comments response`);
this.extractCommentsAndIssueText(commentsData, result, issueId);
}
} else {
this.debug(`[DEBUG] Comments batch failed with ${commentsResponse.status}, trying events API for comments`);
// Extract comments from events API response which we already have
if (result.comments && result.comments.length === 0) {
// Parse events for comments instead
this.extractCommentsFromEventsData(result);
}
}
} catch (e) {
this.debug(`[DEBUG] Comments extraction failed: ${e}`);
}
// Clean up undefined values
Object.keys(result).forEach(key => {
if (result[key] === undefined) {
delete result[key];
}
});
this.debug(`[DEBUG] Direct API extracted data:`, result);
return result;
} catch (error) {
this.debug(`[DEBUG] Direct API extraction failed: ${error}`);
throw error;
}
}
private parseResponseData(responseText: string): any {
if (!responseText) return null;
let data = responseText.trim();
// Remove security prefix if present
if (data.startsWith(")]}'")){
data = data.substring(4).trim();
}
try {
return JSON.parse(data);
} catch (e) {
// Try to extract JSON arrays from the response
const jsonMatch = data.match(/\[\[.*?\]\]/s);
if (jsonMatch) {
try {
return JSON.parse(jsonMatch[0]);
} catch (e2) {
this.debug(`[DEBUG] Could not parse extracted JSON: ${e2}`);
}
}
return null;
}
}
private extractCommentsAndIssueText(commentsData: any, result: any, issueId: string): void {
if (!Array.isArray(commentsData)) return;
// Parse the response structure - based on what we saw earlier
// The structure appears to be: [["b.BatchGetIssueCommentsResponse", null, [comments_array]]]
for (const item of commentsData) {
if (Array.isArray(item) && item.length >= 3) {
const responseType = item[0];
const commentsArray = item[2];
if (typeof responseType === 'string' && responseType.includes('BatchGetIssueCommentsResponse') && Array.isArray(commentsArray)) {
this.debug(`[DEBUG] Found BatchGetIssueCommentsResponse with ${commentsArray.length} items`);
this.parseCommentsArray(commentsArray, result, issueId);
}
}
}
}
private parseCommentsArray(commentsArray: any[], result: any, issueId: string): void {
if (!result.comments) result.comments = [];
for (const commentData of commentsArray) {
if (!Array.isArray(commentData)) continue;
try {
// Based on the structure we observed:
// Each comment seems to have: [author, null, timestamp, content, ...]
const comment = this.parseCommentStructure(commentData);
if (comment && !this.isEmptyMigrationComment(comment)) {
result.comments.push(comment);
// Extract issue title from first comment if it's the main description
if (result.comments.length === 1 && comment.content && comment.content.length > 20) {
// Try to extract a meaningful title from the first few lines
const lines = comment.content.split('\n');
const firstLine = lines[0]?.trim();
if (firstLine && firstLine.length > 10 && firstLine.length < 200 && !result.title) {
result.title = firstLine;
this.debug(`[DEBUG] Extracted title from first comment: ${firstLine}`);
}
// The first comment is usually the issue description
if (!result.description) {
result.description = comment.content;
}
}
// Extract CL references from comment content
this.extractCLReferencesFromText(comment.content, result);
}
} catch (e) {
this.debug(`[DEBUG] Error parsing comment: ${e}`);
}
}
}
private parseCommentStructure(commentData: any[]): any | null {
if (!Array.isArray(commentData) || commentData.length < 4) return null;
try {
// Based on observed structure: [author_info, null, timestamp_info, content, ...]
const authorInfo = commentData[0]; // Usually an email or array with email
const timestampInfo = commentData[2]; // Usually [seconds, nanoseconds]
const contentInfo = commentData[3]; // Content or content wrapper
let author = 'Unknown';
if (typeof authorInfo === 'string') {
author = authorInfo;
} else if (Array.isArray(authorInfo) && authorInfo[0]) {
author = authorInfo[0];
}
let timestamp = null;
if (Array.isArray(timestampInfo) && timestampInfo[0]) {
const seconds = timestampInfo[0];
if (typeof seconds === 'number') {
timestamp = new Date(seconds * 1000).toISOString();
}
}
let content = '';
if (typeof contentInfo === 'string') {
content = contentInfo;
} else if (Array.isArray(contentInfo)) {
// Look for content in nested structure
content = this.extractContentFromStructure(contentInfo);
}
if (content && content.length > 0) {
return {
author,
timestamp,
content: this.cleanupCommentContent(content)
};
}
return null;
} catch (e) {
this.debug(`[DEBUG] Error in parseCommentStructure: ${e}`);
return null;
}
}
private extractContentFromStructure(structure: any): string {
if (typeof structure === 'string') return structure;
if (!Array.isArray(structure)) return '';
let content = '';
for (const item of structure) {
if (typeof item === 'string' && item.length > 0) {
content += item + ' ';
} else if (Array.isArray(item)) {
content += this.extractContentFromStructure(item);
}
}
return content.trim();
}
private cleanupCommentContent(content: string): string {
// Remove HTML tags and decode entities
return content
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
private extractCLReferencesFromText(text: string, result: any): void {
if (!text) return;
const clMatches = text.match(/(?:CL|chromium-review\.googlesource\.com\/c\/chromium\/src\/\+\/)\s*(\d{6,})/g);
if (clMatches) {
if (!result.relatedCLs) result.relatedCLs = [];
clMatches.forEach(match => {
const clNumber = match.match(/(\d{6,})/)?.[1];
if (clNumber && !result.relatedCLs.includes(clNumber)) {
result.relatedCLs.push(clNumber);
this.debug(`[DEBUG] Found CL: ${clNumber}`);
}
});
}
}
private extractEventsData(eventsData: any[], result: any, issueId: string): void {
if (!Array.isArray(eventsData)) return;
// Based on the structure you showed, events contain the detailed issue info and comments
for (const item of eventsData) {
if (Array.isArray(item) && item.length >= 3) {
const responseType = item[0];
const eventsArray = item[2];
if (typeof responseType === 'string' && responseType.includes('ListIssueEventsResponse') && Array.isArray(eventsArray)) {
this.debug(`[DEBUG] Found ListIssueEventsResponse with ${eventsArray.length} events`);
this.parseEventsArray(eventsArray, result, issueId);
}
}
}
}
private parseEventsArray(eventsArray: any[], result: any, issueId: string): void {
if (!result.comments) result.comments = [];
this.debug(`[DEBUG] Parsing ${eventsArray.length} events`);
for (let i = 0; i < eventsArray.length; i++) {
const event = eventsArray[i];
if (!Array.isArray(event)) {
continue;
}
try {
// Look for metadata in event structure first
// Event structure appears to be: [author, timestamp, content, ..., metadata, ...]
// Try to extract metadata from the later positions in the event
if (event.length >= 6 && Array.isArray(event[5])) {
this.extractIssueMetadata(event[5], result);
}
// Look for content in different positions based on actual structure
let content = '';
let author = 'Unknown';
let timestamp = null;
// Based on the structure we observed:
// event[2] can be a string (for comments) or complex array (for issue description)
// event[0][1] contains the author email
// event[1][0] contains the timestamp
if (typeof event[2] === 'string' && event[2].length > 20) {
content = event[2];
this.debug(`[DEBUG] Found string content at position 2: ${content.substring(0, 100)}...`);
} else if (Array.isArray(event[2]) && event[2][0] && typeof event[2][0] === 'string' && event[2][0].length > 20) {
// For the issue description, the content is in event[2][0]
content = event[2][0];
this.debug(`[DEBUG] Found array content at position 2[0]: ${content.substring(0, 100)}...`);
}
if (content) {
// Extract author and timestamp based on observed structure
if (Array.isArray(event[0]) && event[0][1]) {
author = event[0][1];
}
if (Array.isArray(event[1]) && event[1][0]) {
const seconds = event[1][0];
if (typeof seconds === 'number') {
timestamp = new Date(seconds * 1000).toISOString();
}
}
const comment = {
author,
timestamp,
content: this.cleanupCommentContent(content)
};
// Skip empty migration comments
if (!this.isEmptyMigrationComment(comment)) {
result.comments.push(comment);
} else {
this.debug(`[DEBUG] Skipped empty migration comment from ${author}`);
continue;
}
this.debug(`[DEBUG] Added comment from ${author}: ${content.substring(0, 50)}...`);
// Extract CL references from comment content
this.extractCLReferencesFromText(comment.content, result);
// If this is the first comment, it might be the issue description
if (result.comments.length === 1 && !result.description) {
result.description = comment.content;
}
}
} catch (e) {
this.debug(`[DEBUG] Error parsing event ${i}: ${e}`);
}
}
}
private parseEventComment(event: any[]): any | null {
try {
const authorInfo = event[0];
const timestampInfo = event[2];
const content = event[3];
let author = 'Unknown';
if (Array.isArray(authorInfo) && authorInfo[0]) {
author = authorInfo[0];
} else if (typeof authorInfo === 'string') {
author = authorInfo;
}
let timestamp = null;
if (Array.isArray(timestampInfo) && timestampInfo[0]) {
const seconds = timestampInfo[0];
if (typeof seconds === 'number') {
timestamp = new Date(seconds * 1000).toISOString();
}
}
if (content && content.length > 0) {
return {
author,
timestamp,
content: this.cleanupCommentContent(content)
};
}
return null;
} catch (e) {
this.debug(`[DEBUG] Error in parseEventComment: ${e}`);
return null;
}
}
private extractIssueMetadata(metadataArray: any[], result: any): void {
if (!Array.isArray(metadataArray)) return;
this.debug(`[DEBUG] Extracting metadata from array with ${metadataArray.length} items`);
for (const item of metadataArray) {
if (!Array.isArray(item) || item.length < 2) continue;
const fieldName = item[0];
this.debug(`[DEBUG] Processing metadata field: ${fieldName}`, JSON.stringify(item, null, 2).substring(0, 300));
try {
if (typeof fieldName === 'string') {
switch (fieldName) {
case 'title':
// Structure: ["title", null, [null, ["type.googleapis.com/google.protobuf.StringValue", ["Actual Title"]]]]
const titleValue = this.extractNestedProtobufValue(item, 'StringValue');
if (titleValue) {
result.title = titleValue;
this.debug(`[DEBUG] Found title: ${result.title}`);
}
break;
case 'status':
// Structure: ["status", null, [null, ["type.googleapis.com/google.protobuf.Int32Value", [1]]]]
const statusValue = this.extractNestedProtobufValue(item, 'Int32Value');
if (typeof statusValue === 'number') {
result.status = this.getStatusText(statusValue);
this.debug(`[DEBUG] Found status: ${result.status} (${statusValue})`);
}
break;
case 'priority':
const priorityValue = this.extractNestedProtobufValue(item, 'Int32Value');
if (typeof priorityValue === 'number') {
result.priority = this.getPriorityText(priorityValue);
this.debug(`[DEBUG] Found priority: ${result.priority} (${priorityValue})`);
}
break;
case 'type':
const typeValue = this.extractNestedProtobufValue(item, 'Int32Value');
if (typeof typeValue === 'number') {
result.type = this.getTypeText(typeValue);
this.debug(`[DEBUG] Found type: ${result.type} (${typeValue})`);
}
break;
case 'severity':
const severityValue = this.extractNestedProtobufValue(item, 'Int32Value');
if (typeof severityValue === 'number') {
result.severity = this.getSeverityText(severityValue);
this.debug(`[DEBUG] Found severity: ${result.severity} (${severityValue})`);
}
break;
case 'reporter':
// Structure: ["reporter", null, [null, ["type.googleapis.com/google.devtools.issuetracker.v1.User", [null, "email@domain.com", ...]]]]
const reporterUser = this.extractUserFromProtobuf(item);
if (reporterUser) {
result.reporter = reporterUser;
this.debug(`[DEBUG] Found reporter: ${result.reporter}`);
}
break;
case 'assignee':
const assigneeUser = this.extractUserFromProtobuf(item);
if (assigneeUser) {
result.assignee = assigneeUser;
this.debug(`[DEBUG] Found assignee: ${result.assignee}`);
}
break;
}
}
} catch (e) {
this.debug(`[DEBUG] Error parsing metadata field ${fieldName}: ${e}`);
}
}
}
private extractNestedProtobufValue(item: any[], valueType: string): any {
// Navigate the nested protobuf structure to find the actual value
// Typical structure: [fieldName, null, [null, [protobuf_type, [actual_value]]]]
if (!Array.isArray(item) || item.length < 3) return null;
const nestedArray = item[2];
if (!Array.isArray(nestedArray) || nestedArray.length < 2) return null;
const protobufWrapper = nestedArray[1];
if (!Array.isArray(protobufWrapper) || protobufWrapper.length < 2) return null;
const protobufType = protobufWrapper[0];
const value = protobufWrapper[1];
if (typeof protobufType === 'string' && protobufType.includes(valueType) && Array.isArray(value)) {
// For StringValue and Int32Value, the actual value is in value[0]
// For User, the email is typically in value[0]
return value[0];
}
return null;
}
private extractUserFromProtobuf(item: any[]): string | null {
// User structure: ["reporter", null, [null, ["type.googleapis.com/google.devtools.issuetracker.v1.User", [null, "email@domain.com", ...]]]]
if (!Array.isArray(item) || item.length < 3) return null;
const nestedArray = item[2];
if (!Array.isArray(nestedArray) || nestedArray.length < 2) return null;
const protobufWrapper = nestedArray[1];
if (!Array.isArray(protobufWrapper) || protobufWrapper.length < 2) return null;
const protobufType = protobufWrapper[0];
const userArray = protobufWrapper[1];
if (typeof protobufType === 'string' && protobufType.includes('User') && Array.isArray(userArray)) {
// User array structure: [null, "email@domain.com", numeric_id, [permissions]]
// The email is at position 1
if (userArray.length > 1 && typeof userArray[1] === 'string') {
return userArray[1];
}
}
return null;
}
private extractCommentsFromEventsData(result: any): void {
this.debug(`[DEBUG] Attempting to extract comments from events data`);
}
private extractIssueDataFromBatchArray(batchArray: any[], result: any, issueId: string): void {
for (const item of batchArray) {
if (Array.isArray(item)) {
this.extractIssueDataFromBatchArray(item, result, issueId);
} else if (typeof item === 'string') {
// Look for meaningful strings like titles, descriptions, etc.
if (item.length > 20 && !item.includes('Response') && !item.includes('b.')) {
if (!result.title && item.length < 200) {
result.title = item;
this.debug(`[DEBUG] Found title: ${item}`);
} else if (!result.description && item.length > 50) {
result.description = item.substring(0, 500);
this.debug(`[DEBUG] Found description: ${item.substring(0, 100)}...`);
}
// Extract CL references
this.extractCLReferencesFromText(item, result);
}
} else if (typeof item === 'object' && item !== null) {
// Look for structured data patterns that might contain issue info
if (typeof item[0] === 'string') {
const potentialTitle = item[0];
if (potentialTitle && potentialTitle.length > 20 && potentialTitle.length < 200 && !result.title) {
result.title = potentialTitle;
this.debug(`[DEBUG] Found structured title: ${potentialTitle}`);
}
}
}
}
}
private isEmptyMigrationComment(comment: any): boolean {
if (!comment || !comment.content) return false;
const content = comment.content.toLowerCase().trim();
return content === "[empty comment from monorail migration]" ||
content === "empty comment from monorail migration" ||
content.includes("empty comment from monorail migration");
}
// PDFium Gerrit operations
async getPdfiumGerritCLStatus(clNumber: string): Promise<any> {
try {
// Extract CL number from URL if needed
const clNum = this.extractCLNumber(clNumber);
const gerritUrl = `https://pdfium-review.googlesource.com/changes/${clNum}?o=CURRENT_REVISION&o=DETAILED_ACCOUNTS&o=SUBMIT_REQUIREMENTS&o=CURRENT_COMMIT`;
const response = await fetch(gerritUrl);
if (!response.ok) {
throw new GerritAPIError(`Failed to fetch PDFium CL status: ${response.status}`, response.status);
}
const text = await response.text();
// Remove XSSI protection prefix
const jsonText = text.replace(/^\)\]\}'\n/, '');
return JSON.parse(jsonText);
} catch (error: any) {
throw new GerritAPIError(`PDFium Gerrit API error: ${error.message}`, undefined, error);
}
}
async getPdfiumGerritCLComments(params: GetGerritCLCommentsParams): Promise<any> {
try {
const clNum = this.extractCLNumber(params.clNumber);
const gerritUrl = `https://pdfium-review.googlesource.com/changes/${clNum}/comments`;
const response = await fetch(gerritUrl);
if (!response.ok) {
throw new GerritAPIError(`Failed to fetch PDFium CL comments: ${response.status}`, response.status);
}
const text = await response.text();
const jsonText = text.replace(/^\)\]\}'\n/, '');
return JSON.parse(jsonText);
} catch (error: any) {
throw new GerritAPIError(`PDFium Gerrit comments API error: ${error.message}`, undefined, error);
}
}
async getPdfiumGerritCLDiff(params: GetGerritCLDiffParams): Promise<any> {
const clId = this.extractCLNumber(params.clNumber);
try {
// First get CL details to know current patchset if not specified
const clDetailsUrl = `https://pdfium-review.googlesource.com/changes/?q=change:${clId}&o=CURRENT_REVISION`;
const clResponse = await fetch(clDetailsUrl, {
headers: {
'Accept': 'application/json',
},
});
if (!clResponse.ok) {
throw new Error(`Failed to fetch PDFium CL details: ${clResponse.status}`);
}
const responseText = await clResponse.text();
const jsonText = responseText.replace(/^\)\]\}'\n/, '');
const clData = JSON.parse(jsonText);
if (!clData || clData.length === 0) {
throw new Error(`PDFium CL ${clId} not found`);
}
const cl = clData[0];
const targetPatchset = params.patchset || cl.current_revision_number || 1;
const revision = cl.revisions[cl.current_revision];
// Get the files list first to understand what changed
const filesUrl = `https://pdfium-review.googlesource.com/changes/${clId}/revisions/${targetPatchset}/files`;
const filesResponse = await fetch(filesUrl, {
headers: {
'Accept': 'application/json',
},
});
if (!filesResponse.ok) {
throw new Error(`Failed to fetch PDFium files: ${filesResponse.status}`);
}
const filesText = await filesResponse.text();
const filesJsonText = filesText.replace(/^\)\]\}'\n/, '');
const filesData = JSON.parse(filesJsonText);
const changedFiles = Object.keys(filesData).filter(f => f !== '/COMMIT_MSG');
const result: any = {
clId,
subject: cl.subject,
patchset: targetPatchset,
author: cl.owner.name,
changedFiles,
filesData,
revision,
isPdfium: true
};
if (params.filePath) {
// Get diff for specific file
if (!filesData[params.filePath]) {
result.error = `File ${params.filePath} not found in PDFium patchset ${targetPatchset}`;
return result;
}
const diffUrl = `https://pdfium-review.googlesource.com/changes/${clId}/revisions/${targetPatchset}/files/${encodeURIComponent(params.filePath)}/diff?base=${targetPatchset-1}&context=ALL&intraline`;
const diffResponse = await fetch(diffUrl, {
headers: {
'Accept': 'application/json',
},
});
if (diffResponse.ok) {
const diffText = await diffResponse.text();
const diffJsonText = diffText.replace(/^\)\]\}'\n/, '');
result.diffData = JSON.parse(diffJsonText);
}
}
return result;
} catch (error: any) {
throw new GerritAPIError(`Failed to get PDFium CL diff: ${error.message}`, undefined, error);
}
}
async getPdfiumGerritPatchsetFile(params: GetGerritPatchsetFileParams): Promise<any> {
const clId = this.extractCLNumber(params.clNumber);
try {
// First get CL details to know current patchset if not specified
const clDetailsUrl = `https://pdfium-review.googlesource.com/changes/?q=change:${clId}&o=CURRENT_REVISION`;
const clResponse = await fetch(clDetailsUrl, {
headers: {
'Accept': 'application/json',
},
});
if (!clResponse.ok) {
throw new Error(`Failed to fetch PDFium CL details: ${clResponse.status}`);
}
const responseText = await clResponse.text();
const jsonText = responseText.replace(/^\)\]\}'\n/, '');
const clData = JSON.parse(jsonText);
if (!clData || clData.length === 0) {
throw new Error(`PDFium CL ${clId} not found`);
}
const cl = clData[0];
const targetPatchset = params.patchset || cl.current_revision_number || 1;
// Get the file content from the patchset
const fileUrl = `https://pdfium-review.googlesource.com/changes/${clId}/revisions/${targetPatchset}/files/${encodeURIComponent(params.filePath)}/content`;
const fileResponse = await fetch(fileUrl, {
headers: {
'Accept': 'text/plain',
},
});
if (!fileResponse.ok) {
if (fileResponse.status === 404) {
throw new Error(`File ${params.filePath} not found in PDFium patchset ${targetPatchset}`);
}
throw new Error(`Failed to fetch PDFium file content: ${fileResponse.status}`);
}
// Gerrit returns base64 encoded content
const base64Content = await fileResponse.text();
const content = Buffer.from(base64Content, 'base64').toString('utf-8');
return {
clId,
subject: cl.subject,
patchset: targetPatchset,
author: cl.owner.name,
filePath: params.filePath,
content,
lines: content.split('\n').length,
isPdfium: true
};
} catch (error: any) {
throw new GerritAPIError(`Failed to get PDFium file content: ${error.message}`, undefined, error);
}
}
async getPdfiumGerritCLTrybotStatus(params: { clNumber: string; patchset?: number; failedOnly?: boolean }): Promise<any> {
const clId = this.extractCLNumber(params.clNumber);
try {
// Get CL messages to find LUCI Change Verifier URLs (PDFium)
const messagesUrl = `https://pdfium-review.googlesource.com/changes/${clId}/messages`;
const response = await fetch(messagesUrl, {
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch PDFium messages: ${response.status}`);
}
const text = await response.text();
const jsonText = text.replace(/^\)\]\}'\n/, '');
const messages = JSON.parse(jsonText);
// Find LUCI Change Verifier URLs from messages (PDFium might use different patterns)
const luciUrls = this.extractPdfiumLuciVerifierUrls(messages, params.patchset);
if (luciUrls.length === 0) {
return {
clId,
patchset: params.patchset || 'latest',
totalBots: 0,
failedBots: 0,
passedBots: 0,
runningBots: 0,
bots: [],
isPdfium: true,
message: 'No LUCI runs found for this PDFium CL'
};
}
// Get detailed bot status from the most recent LUCI run
const latestLuciUrl = luciUrls[0];
const detailedBots = await this.fetchLuciRunDetails(latestLuciUrl.url);
// Filter by failed only if requested
const filteredBots = params.failedOnly
? detailedBots.filter(bot => bot.status === 'FAILED')
: detailedBots;
return {
clId,
patchset: latestLuciUrl.patchset,
runId: latestLuciUrl.runId,
luciUrl: latestLuciUrl.url,
totalBots: detailedBots.length,
failedBots: detailedBots.filter(bot => bot.status === 'FAILED').length,
passedBots: detailedBots.filter(bot => bot.status === 'PASSED').length,
runningBots: detailedBots.filter(bot => bot.status === 'RUNNING').length,
canceledBots: detailedBots.filter(bot => bot.status === 'CANCELED').length,
bots: filteredBots,
timestamp: latestLuciUrl.timestamp,
isPdfium: true
};
} catch (error: any) {
throw new GerritAPIError(`Failed to get PDFium trybot status: ${error.message}`, undefined, error);
}
}
private extractPdfiumLuciVerifierUrls(messages: any[], targetPatchset?: number): Array<{
url: string;
runId: string;
patchset: number;
timestamp: string;
}> {
const luciUrls: Array<{ url: string; runId: string; patchset: number; timestamp: string }> = [];
for (const msg of messages) {
// Skip if we want a specific patchset and this message is for a different one
if (targetPatchset && msg._revision_number && msg._revision_number !== targetPatchset) {
continue;
}
// Look for LUCI Change Verifier URLs in messages (PDFium might have different URL patterns)
if (msg.message) {
// Try standard PDFium LUCI pattern
let luciMatch = msg.message.match(/Follow status at: (https:\/\/luci-change-verifier\.appspot\.com\/ui\/run\/pdfium\/([^\/\s]+))/);
// If PDFium-specific pattern doesn't match, try the standard Chromium pattern
if (!luciMatch) {
luciMatch = msg.message.match(/Follow status at: (https:\/\/luci-change-verifier\.appspot\.com\/ui\/run\/chromium\/([^\/\s]+))/);
}
if (luciMatch) {
luciUrls.push({
url: luciMatch[1],
runId: luciMatch[2],
patchset: msg._revision_number || 0,
timestamp: msg.date
});
}
}
}
// Return most recent first
return luciUrls.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
async searchIssues(query: string, options: { limit?: number; startIndex?: number } = {}): Promise<any> {
const { limit = 50, startIndex = 0 } = options;
this.debug(`[DEBUG] Searching issues with query: "${query}", limit: ${limit}, startIndex: ${startIndex}`);
try {
const baseUrl = 'https://issues.chromium.org';
const endpoint = '/action/issues/list';
// Based on the curl command structure: [null,null,null,null,null,["157"],["pkasting","modified_time desc",50,"start_index:0"]]
const searchParams = [query, "modified_time desc", limit];
if (startIndex > 0) {
searchParams.push(`start_index:${startIndex}`);
}
const payload = [
null,
null,
null,
null,
null,
["157"], // Track ID for Chromium
searchParams
];
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Referer': 'https://issues.chromium.org/',
'Origin': 'https://issues.chromium.org',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"macOS"'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const text = await response.text();
this.debug(`[DEBUG] Raw response length: ${text.length} characters`);
// Strip the XSSI protection prefix ")]}'\n" if present
let cleanText = text;
if (text.startsWith(")]}'\n")) {
cleanText = text.substring(5);
this.debug(`[DEBUG] Stripped XSSI protection prefix`);
} else if (text.startsWith(")]}'")) {
cleanText = text.substring(4);
this.debug(`[DEBUG] Stripped alternative XSSI protection prefix`);
}
// Parse the response (should be JSON)
const data = JSON.parse(cleanText);
this.debug(`[DEBUG] Parsed response structure:`, typeof data, Array.isArray(data));
this.debug(`[DEBUG] Response top-level structure:`, data.length ? `Array of ${data.length} items` : 'Not an array');
// Extract issues from the response
const issues = this.parseIssueSearchResults(data, query);
return {
query,
total: issues.length,
issues,
searchUrl: `${baseUrl}/issues?q=${encodeURIComponent(query)}`
};
} catch (error) {
this.debug(`[ERROR] Issue search failed:`, error);
throw new ChromiumSearchError(
`Failed to search issues: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
);
}
}
private parseIssueSearchResults(data: any, query: string): any[] {
this.debug(`[DEBUG] Parsing issue search results for query: "${query}"`);
const issues: any[] = [];
// The response structure is: [{ 0: "b.IssueSearchResponse", ..., 6: [[[issueData, ...]], ...] }]
try {
if (data && data[0] && data[0][6] && Array.isArray(data[0][6])) {
const issueContainer = data[0][6];
this.debug(`[DEBUG] Found issue container with ${issueContainer.length} items`);
for (let i = 0; i < issueContainer.length; i++) {
const item = issueContainer[i];
if (Array.isArray(item)) {
// Check if this is a direct issue array
if (item.length > 5 && typeof item[1] === 'number' && item[1] > 1000000) {
const issue = this.parseIssueFromProtobufArray(item);
if (issue) {
issues.push(issue);
this.debug(`[DEBUG] Parsed issue: ${issue.issueId}`);
}
}
// Check if this contains nested issue arrays - look through all positions
else if (item.length > 0) {
for (let j = 0; j < item.length; j++) {
if (Array.isArray(item[j]) && item[j].length > 5) {
// Check if this looks like an issue array (has issue ID at position 1)
if (typeof item[j][1] === 'number' && item[j][1] > 1000000) {
const issue = this.parseIssueFromProtobufArray(item[j]);
if (issue) {
issues.push(issue);
this.debug(`[DEBUG] Parsed issue: ${issue.issueId}`);
}
}
}
}
}
}
}
} else {
this.debug(`[DEBUG] Expected structure not found in response`);
}
} catch (error) {
this.debug(`[DEBUG] Error parsing issue search results:`, error);
}
this.debug(`[DEBUG] Found ${issues.length} issues in search results`);
return issues.map(issue => this.normalizeIssueSearchResult(issue));
}
private parseIssueFromProtobufArray(arr: any[]): any | null {
try {
// Based on the structure observed:
// [null, issueId, [nested_data], timestamp1, timestamp2, null, null, null, status_num, [priority], ...]
const issue: any = {};
// Issue ID is at position 1 (this appears to be the correct main issue ID)
if (arr[1] && typeof arr[1] === 'number') {
issue.issueId = arr[1].toString();
}
// Nested issue data is at position 2
if (arr[2] && Array.isArray(arr[2]) && arr[2].length > 5) {
const nestedData = arr[2];
// Don't override the main issue ID with the nested one
// The nested ID might be different (internal ID vs public ID)
// Title at nestedData[5]
if (nestedData[5] && typeof nestedData[5] === 'string') {
issue.title = nestedData[5];
}
// Reporter at nestedData[6] - format: [null, "email", 1]
if (nestedData[6] && Array.isArray(nestedData[6]) && nestedData[6][1]) {
issue.reporter = nestedData[6][1];
}
// Assignee at nestedData[7] - format: [null, "email", 1]
if (nestedData[7] && Array.isArray(nestedData[7]) && nestedData[7][1]) {
issue.assignee = nestedData[7][1];
}
// Status (numeric) might be at position 1, 2, 3, or 4
if (typeof nestedData[1] === 'number') {
issue.statusNum = nestedData[1];
issue.status = this.getIssueStatusFromNumber(nestedData[1]);
}
// Priority (numeric) might be at position 2
if (typeof nestedData[2] === 'number') {
issue.priorityNum = nestedData[2];
issue.priority = `P${nestedData[2]}`;
}
// Type (numeric) might be at position 3
if (typeof nestedData[3] === 'number') {
issue.typeNum = nestedData[3];
issue.type = this.getIssueTypeFromNumber(nestedData[3]);
}
// Severity (numeric) might be at position 4
if (typeof nestedData[4] === 'number') {
issue.severityNum = nestedData[4];
issue.severity = `S${nestedData[4]}`;
}
}
// Timestamps - arr[3] is created, arr[4] is modified
if (arr[3] && typeof arr[3] === 'number') {
issue.created = new Date(arr[3] * 1000).toISOString();
}
if (arr[4] && Array.isArray(arr[4]) && arr[4][0]) {
issue.modified = new Date(arr[4][0] * 1000).toISOString();
}
// Status and priority might also be in later positions
if (arr[8] && typeof arr[8] === 'number') {
issue.statusNum = arr[8];
issue.status = this.getIssueStatusFromNumber(arr[8]);
}
if (arr[9] && Array.isArray(arr[9]) && arr[9][0]) {
issue.priorityNum = arr[9][0];
issue.priority = `P${arr[9][0]}`;
}
// Type info at position 12 - this is the main source
if (arr[12] && typeof arr[12] === 'number') {
issue.typeNum = arr[12];
issue.type = this.getIssueTypeFromNumber(arr[12]);
}
// View counts - position varies, look for metrics data
// The structure shows view counts around positions 15-20
for (let i = 10; i < Math.min(arr.length, 25); i++) {
if (arr[i] && typeof arr[i] === 'object' && !Array.isArray(arr[i])) {
// Look for numeric view count properties
const keys = Object.keys(arr[i]);
if (keys.length > 0) {
const viewCount = arr[i][keys[0]];
if (typeof viewCount === 'number' && viewCount >= 0) {
issue.views7Days = viewCount;
break;
}
}
}
}
// CL information - check custom fields array which is typically at the end
// Position varies but usually after position 15
if (arr.length > 15) {
for (let i = 15; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
// Look for custom field data that might contain CL links
for (const field of arr[i]) {
if (Array.isArray(field) && field.length > 3) {
const fieldName = field[3];
const fieldValue = field[6] || field[5] || field[4];
// Check for CL-related field names or values
if (fieldName && typeof fieldName === 'string' &&
(fieldName.toLowerCase().includes('cl') || fieldName.toLowerCase().includes('review'))) {
if (fieldValue) {
issue.hasCL = true;
issue.clInfo = fieldValue;
break;
}
}
// Also check field values for CL URLs
if (fieldValue && typeof fieldValue === 'string' &&
(fieldValue.includes('chromium-review.googlesource.com') ||
fieldValue.includes('/c/chromium/') ||
fieldValue.includes('crrev.com/c/'))) {
issue.hasCL = true;
issue.clInfo = fieldValue;
break;
}
}
}
if (issue.hasCL) break;
}
}
}
if (issue.issueId) {
issue.browserUrl = `https://issues.chromium.org/issues/${issue.issueId}`;
return issue;
}
return null;
} catch (error) {
this.debug(`[DEBUG] Error parsing single issue:`, error);
return null;
}
}
private getIssueStatusFromNumber(statusNum: number): string {
// Common status mappings (may need adjustment based on actual data)
const statusMap: { [key: number]: string } = {
1: 'NEW',
2: 'ASSIGNED',
3: 'ACCEPTED',
4: 'FIXED',
5: 'VERIFIED',
6: 'CLOSED',
7: 'DUPLICATE',
8: 'WontFix',
9: 'Invalid'
};
return statusMap[statusNum] || `Status${statusNum}`;
}
private getIssueTypeFromNumber(typeNum: number): string {
// Common type mappings (may need adjustment based on actual data)
const typeMap: { [key: number]: string } = {
1: 'Bug',
2: 'Feature',
3: 'Task',
4: 'Enhancement'
};
return typeMap[typeNum] || `Type${typeNum}`;
}
private looksLikeIssueObject(obj: any): boolean {
// Check if object has properties that look like issue fields
if (typeof obj !== 'object' || obj === null) return false;
const keys = Object.keys(obj);
const issueKeys = ['id', 'title', 'status', 'priority', 'type', 'reporter', 'assignee', 'created', 'modified'];
return issueKeys.some(key => keys.includes(key)) ||
keys.some(key => key.toLowerCase().includes('issue'));
}
private looksLikeIssueData(arr: any[]): boolean {
// Check if array contains issue-like data structures
if (!Array.isArray(arr) || arr.length === 0) return false;
// Look for nested arrays that might contain issue IDs (numeric strings)
return arr.some(item => {
if (Array.isArray(item)) {
return item.some(subItem => {
if (typeof subItem === 'string' && /^\d{9,}$/.test(subItem)) {
return true; // Looks like an issue ID
}
return false;
});
}
return false;
});
}
private extractIssuesFromArray(arr: any[]): any[] {
const issues: any[] = [];
// Parse the nested array structure to extract issue information
for (const item of arr) {
if (Array.isArray(item)) {
const issueData = this.parseIssueFromNestedArray(item);
if (issueData) {
issues.push(issueData);
}
}
}
return issues;
}
private parseIssueFromNestedArray(arr: any[]): any | null {
// Try to extract issue information from nested array structure
// The exact structure depends on the API response format
let issueId: string | null = null;
let title: string | null = null;
let status: string | null = null;
let priority: string | null = null;
let reporter: string | null = null;
let assignee: string | null = null;
let created: string | null = null;
let modified: string | null = null;
const traverse = (item: any) => {
if (Array.isArray(item)) {
item.forEach(traverse);
} else if (typeof item === 'string') {
// Look for issue ID pattern
if (/^\d{9,}$/.test(item) && !issueId) {
issueId = item;
}
// Look for email patterns
else if (item.includes('@') && item.includes('.')) {
if (!reporter) {
reporter = item;
} else if (!assignee && item !== reporter) {
assignee = item;
}
}
// Look for status/priority patterns
else if (/^(NEW|ASSIGNED|FIXED|VERIFIED|CLOSED|DUPLICATE|WontFix|Invalid)$/i.test(item)) {
status = item;
}
else if (/^P[0-4]$/.test(item)) {
priority = item;
}
// Look for title (longer text that doesn't match other patterns)
else if (item.length > 20 && item.length < 200 && !title &&
!item.includes('http') && !item.includes('@')) {
title = item;
}
// Look for timestamps
else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(item)) {
if (!created) {
created = item;
} else if (!modified) {
modified = item;
}
}
}
};
traverse(arr);
if (issueId) {
return {
issueId,
title: title || `Issue ${issueId}`,
status: status || 'Unknown',
priority: priority || 'Unknown',
reporter,
assignee,
created,
modified,
browserUrl: `https://issues.chromium.org/issues/${issueId}`
};
}
return null;
}
private normalizeIssueSearchResult(issue: any): any {
// Normalize the issue object to a consistent format
return {
issueId: issue.issueId || issue.id || 'Unknown',
title: issue.title || 'No title available',
type: issue.type || 'Unknown',
status: issue.status || 'Unknown',
priority: issue.priority || 'Unknown',
reporter: issue.reporter || null,
assignee: issue.assignee || null,
created: issue.created || null,
modified: issue.modified || null,
views7Days: issue.views7Days || 0,
hasCL: issue.hasCL || false,
clInfo: issue.clInfo || null,
browserUrl: issue.browserUrl || `https://issues.chromium.org/issues/${issue.issueId || issue.id}`
};
}
async listFolder(folderPath: string): Promise<any> {
try {
// Normalize folder path - remove leading/trailing slashes
const normalizedPath = folderPath.replace(/^\/+|\/+$/g, '');
// Use Gitiles API to get directory listing
const gitilesUrl = `https://chromium.googlesource.com/chromium/src/+/main/${normalizedPath}/?format=JSON`;
this.debug('[DEBUG] Fetching folder listing from Gitiles:', gitilesUrl);
const response = await fetch(gitilesUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
});
if (!response.ok) {
// Check if this might be a path inside a submodule
const knownSubmodules = ['v8', 'third_party/webrtc', 'third_party/devtools-frontend'];
const isSubmodulePath = knownSubmodules.some(submodule =>
normalizedPath.startsWith(submodule + '/') || normalizedPath === submodule
);
if (response.status === 404 && isSubmodulePath) {
// Try to fetch from submodule's own repository
const submodule = knownSubmodules.find(sm => normalizedPath.startsWith(sm));
if (submodule === 'v8' || normalizedPath.startsWith('v8/')) {
return await this.listV8FolderViaGitHub(normalizedPath);
}
if (submodule === 'third_party/webrtc' || normalizedPath.startsWith('third_party/webrtc/')) {
return await this.listWebRTCFolderViaGitiles(normalizedPath);
}
throw new ChromiumSearchError(
`Cannot list folder: "${normalizedPath}" appears to be inside the "${submodule}" Git submodule. ` +
`Submodule contents cannot be listed through the main Chromium repository's API. ` +
`You can browse it at: https://source.chromium.org/chromium/chromium/src/+/main:${normalizedPath}/`
);
}
throw new ChromiumSearchError(`Failed to fetch folder listing: HTTP ${response.status}`);
}
const responseText = await response.text();
// Remove XSSI prefix
const jsonText = responseText.replace(/^\)]}'/, '');
const data = JSON.parse(jsonText);
// Check if this is a submodule (like v8)
if (data.url && data.revision && !data.entries) {
// This is a submodule - try to fetch from the submodule's repository
if (data.url.includes('v8/v8')) {
return await this.listV8FolderViaGitHub(normalizedPath);
}
if (data.url.includes('webrtc')) {
return await this.listWebRTCFolderViaGitiles(normalizedPath);
}
// For other submodules, throw error
throw new ChromiumSearchError(
`Cannot list folder: "${normalizedPath}" is a Git submodule pointing to ${data.url}. ` +
`Submodules cannot be listed through the Gitiles API. ` +
`You can browse it at: https://source.chromium.org/chromium/chromium/src/+/main:${normalizedPath}/`
);
}
// Parse the Gitiles response
const items: Array<{name: string, type: 'file' | 'folder'}> = [];
if (data && data.entries && Array.isArray(data.entries)) {
for (const entry of data.entries) {
if (entry.name && entry.type) {
items.push({
name: entry.name,
type: entry.type === 'tree' ? 'folder' : 'file'
});
}
}
}
// Sort items: folders first, then files, alphabetically
items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
const folders = items.filter(item => item.type === 'folder');
const files = items.filter(item => item.type === 'file');
return {
path: normalizedPath,
browserUrl: `https://source.chromium.org/chromium/chromium/src/+/main:${normalizedPath}/`,
totalItems: items.length,
folders: folders.length,
files: files.length,
items: items
};
} catch (error: any) {
this.debug('[DEBUG] Error listing folder:', error);
if (error instanceof ChromiumSearchError) {
throw error;
}
throw new ChromiumSearchError(`Failed to list folder: ${error.message}`, error);
}
}
private async listV8FolderViaGitHub(path: string): Promise<any> {
try {
// Remove 'v8/' prefix if present
const v8Path = path.startsWith('v8/') ? path.substring(3) : path;
// Use GitHub API to list contents
const githubApiUrl = `https://api.github.com/repos/v8/v8/contents/${v8Path}`;
this.debug('[DEBUG] Fetching V8 folder from GitHub:', githubApiUrl);
const response = await fetch(githubApiUrl, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'chromium-helper-cli',
},
});
if (!response.ok) {
throw new ChromiumSearchError(`Failed to fetch V8 folder from GitHub: HTTP ${response.status}`);
}
const items = await response.json() as any[];
// Convert GitHub API response to our format
const formattedItems = items.map((item: any) => ({
name: item.name,
type: item.type === 'dir' ? 'folder' : 'file'
}));
// Sort items: folders first, then files, alphabetically
formattedItems.sort((a: any, b: any) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
const folders = formattedItems.filter((item: any) => item.type === 'folder');
const files = formattedItems.filter((item: any) => item.type === 'file');
return {
path: path,
browserUrl: `https://source.chromium.org/chromium/chromium/src/+/main:${path}/`,
githubUrl: `https://github.com/v8/v8/tree/main/${v8Path}`,
totalItems: formattedItems.length,
folders: folders.length,
files: files.length,
items: formattedItems,
source: 'GitHub (V8 submodule)'
};
} catch (error: any) {
this.debug('[DEBUG] Error fetching V8 folder from GitHub:', error);
if (error instanceof ChromiumSearchError) {
throw error;
}
throw new ChromiumSearchError(`Failed to list V8 folder: ${error.message}`, error);
}
}
private async listWebRTCFolderViaGitiles(path: string): Promise<any> {
try {
// Remove 'third_party/webrtc/' prefix
const webrtcPath = path.replace(/^third_party\/webrtc\/?/, '');
// Use WebRTC's Gitiles API
const gitilesUrl = `https://webrtc.googlesource.com/src/+/main/${webrtcPath}?format=JSON`;
this.debug('[DEBUG] Fetching WebRTC folder from Gitiles:', gitilesUrl);
const response = await fetch(gitilesUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
});
if (!response.ok) {
throw new ChromiumSearchError(`Failed to fetch WebRTC folder: HTTP ${response.status}`);
}
const responseText = await response.text();
const jsonText = responseText.replace(/^\)]}'/, '');
const data = JSON.parse(jsonText);
// Parse the Gitiles response
const items: Array<{name: string, type: 'file' | 'folder'}> = [];
if (data && data.entries && Array.isArray(data.entries)) {
for (const entry of data.entries) {
if (entry.name && entry.type) {
items.push({
name: entry.name,
type: entry.type === 'tree' ? 'folder' : 'file'
});
}
}
}
// Sort items: folders first, then files, alphabetically
items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
const folders = items.filter(item => item.type === 'folder');
const files = items.filter(item => item.type === 'file');
return {
path: path,
browserUrl: `https://source.chromium.org/chromium/chromium/src/+/main:${path}/`,
webrtcUrl: `https://webrtc.googlesource.com/src/+/main/${webrtcPath}`,
totalItems: items.length,
folders: folders.length,
files: files.length,
items: items,
source: 'WebRTC Gitiles (submodule)'
};
} catch (error: any) {
this.debug('[DEBUG] Error fetching WebRTC folder:', error);
if (error instanceof ChromiumSearchError) {
throw error;
}
throw new ChromiumSearchError(`Failed to list WebRTC folder: ${error.message}`, error);
}
}
private async getV8FileViaGitHub(filePath: string, lineStart?: number, lineEnd?: number): Promise<any> {
try {
// Remove 'v8/' prefix
const v8Path = filePath.substring(3);
// Use GitHub raw content URL
const githubUrl = `https://raw.githubusercontent.com/v8/v8/main/${v8Path}`;
this.debug('[DEBUG] Fetching V8 file from GitHub:', githubUrl);
const response = await fetch(githubUrl, {
headers: {
'User-Agent': 'chromium-helper-cli',
},
});
if (!response.ok) {
throw new ChromiumSearchError(`Failed to fetch V8 file from GitHub: HTTP ${response.status}`);
}
const fileContent = await response.text();
const lines = fileContent.split('\n');
let displayLines = lines;
let startLine = 1;
// Apply line range if specified
if (lineStart) {
const start = Math.max(1, lineStart) - 1;
const end = lineEnd ? Math.min(lines.length, lineEnd) : lines.length;
displayLines = lines.slice(start, end);
startLine = start + 1;
}
// Format content with line numbers
const numberedLines = displayLines.map((line, index) => {
const lineNum = (startLine + index).toString().padStart(4, ' ');
return `${lineNum} ${line}`;
}).join('\n');
// Create browser URLs
let browserUrl = `https://source.chromium.org/chromium/chromium/src/+/main:${filePath}`;
let githubBrowserUrl = `https://github.com/v8/v8/blob/main/${v8Path}`;
if (lineStart) {
browserUrl += `;l=${lineStart}`;
githubBrowserUrl += `#L${lineStart}`;
if (lineEnd) {
browserUrl += `-${lineEnd}`;
githubBrowserUrl += `-L${lineEnd}`;
}
}
return {
filePath,
content: numberedLines,
totalLines: lines.length,
displayedLines: displayLines.length,
lineStart,
lineEnd,
browserUrl,
githubUrl: githubBrowserUrl,
source: 'GitHub (V8 submodule)'
};
} catch (error: any) {
this.debug('[DEBUG] Error fetching V8 file from GitHub:', error);
if (error instanceof ChromiumSearchError) {
throw error;
}
throw new ChromiumSearchError(`Failed to fetch V8 file: ${error.message}`, error);
}
}
private async getWebRTCFileViaGitiles(filePath: string, lineStart?: number, lineEnd?: number): Promise<any> {
try {
// Remove 'third_party/webrtc/' prefix
const webrtcPath = filePath.replace(/^third_party\/webrtc\/?/, '');
// Use WebRTC's Gitiles API
const gitilesUrl = `https://webrtc.googlesource.com/src/+/main/${webrtcPath}?format=TEXT`;
this.debug('[DEBUG] Fetching WebRTC file from Gitiles:', gitilesUrl);
const response = await fetch(gitilesUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
});
if (!response.ok) {
throw new ChromiumSearchError(`Failed to fetch WebRTC file: HTTP ${response.status}`);
}
// The response is base64 encoded
const base64Content = await response.text();
const fileContent = Buffer.from(base64Content, 'base64').toString('utf-8');
const lines = fileContent.split('\n');
let displayLines = lines;
let startLine = 1;
// Apply line range if specified
if (lineStart) {
const start = Math.max(1, lineStart) - 1;
const end = lineEnd ? Math.min(lines.length, lineEnd) : lines.length;
displayLines = lines.slice(start, end);
startLine = start + 1;
}
// Format content with line numbers
const numberedLines = displayLines.map((line, index) => {
const lineNum = (startLine + index).toString().padStart(4, ' ');
return `${lineNum} ${line}`;
}).join('\n');
// Create URLs
let browserUrl = `https://source.chromium.org/chromium/chromium/src/+/main:${filePath}`;
let webrtcBrowserUrl = `https://webrtc.googlesource.com/src/+/main/${webrtcPath}`;
if (lineStart) {
browserUrl += `;l=${lineStart}`;
webrtcBrowserUrl += `#${lineStart}`;
if (lineEnd) {
browserUrl += `-${lineEnd}`;
}
}
return {
filePath,
content: numberedLines,
totalLines: lines.length,
displayedLines: displayLines.length,
lineStart,
lineEnd,
browserUrl,
webrtcUrl: webrtcBrowserUrl,
source: 'WebRTC Gitiles (submodule)'
};
} catch (error: any) {
this.debug('[DEBUG] Error fetching WebRTC file:', error);
if (error instanceof ChromiumSearchError) {
throw error;
}
throw new ChromiumSearchError(`Failed to fetch WebRTC file: ${error.message}`, error);
}
}
async listGerritCLs(params: { query?: string; authCookie: string; limit?: number }): Promise<any> {
const { query, authCookie, limit = 25 } = params;
try {
// Build the Gerrit API URL
let apiUrl = 'https://chromium-review.googlesource.com/changes/?';
// Add default options
const urlParams = new URLSearchParams();
// Use simpler options that work with basic auth
urlParams.append('O', '81'); // Basic LABELS option
urlParams.append('S', '0'); // Start index
// Build the query
if (query) {
urlParams.append('q', query);
} else {
// Default to showing user's open CLs
urlParams.append('q', 'status:open owner:self');
}
// Limit results
if (limit && limit > 0 && limit <= 100) {
urlParams.append('n', limit.toString());
}
urlParams.append('allow-incomplete-results', 'true');
apiUrl += urlParams.toString();
this.debug('[DEBUG] Fetching Gerrit CLs:', apiUrl);
this.debug('[DEBUG] Using auth cookie:', authCookie.substring(0, 50) + '...');
// Make the request with authentication cookies
const response = await fetch(apiUrl, {
headers: {
'Accept': 'application/json',
'Cookie': authCookie,
},
redirect: 'manual' // Don't follow redirects - if we get a redirect, it means auth failed
});
if (!response.ok) {
// Log response details for debugging
const responseText = await response.text();
this.debug(`[DEBUG] Response status: ${response.status}`);
this.debug(`[DEBUG] Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`);
this.debug(`[DEBUG] Response body (first 500 chars): ${responseText.substring(0, 500)}`);
if (response.status === 403) {
throw new Error('Authentication failed. Please ensure your cookies include both __Secure-1PSID and __Secure-3PSID');
}
throw new Error(`Failed to fetch CLs: HTTP ${response.status}`);
}
const responseText = await response.text();
// Remove XSSI prefix
const jsonText = responseText.replace(/^\)]}'/, '');
const cls = JSON.parse(jsonText);
if (!Array.isArray(cls)) {
throw new Error('Unexpected response format from Gerrit API');
}
return cls;
} catch (error: any) {
throw new GerritAPIError(`Failed to list Gerrit CLs: ${error.message}`, undefined, error);
}
}
async listPdfiumGerritCLs(params: { query?: string; authCookie: string; limit?: number }): Promise<any> {
const { query, authCookie, limit = 25 } = params;
try {
// Build the PDFium Gerrit API URL
let apiUrl = 'https://pdfium-review.googlesource.com/changes/?';
// Add default options
const urlParams = new URLSearchParams();
// Use simpler options that work with basic auth
urlParams.append('O', '81'); // Basic LABELS option
urlParams.append('S', '0'); // Start index
// Build the query
if (query) {
urlParams.append('q', query);
} else {
// Default to showing user's open CLs
urlParams.append('q', 'status:open owner:self');
}
// Limit results
if (limit && limit > 0 && limit <= 100) {
urlParams.append('n', limit.toString());
}
urlParams.append('allow-incomplete-results', 'true');
apiUrl += urlParams.toString();
this.debug('[DEBUG] Fetching PDFium Gerrit CLs:', apiUrl);
// Make the request with authentication cookies
const response = await fetch(apiUrl, {
headers: {
'Accept': 'application/json',
'Cookie': authCookie,
},
redirect: 'manual' // Don't follow redirects - if we get a redirect, it means auth failed
});
if (!response.ok) {
if (response.status === 403) {
throw new Error('Authentication failed. Please ensure your cookies include both __Secure-1PSID and __Secure-3PSID');
}
throw new Error(`Failed to fetch CLs: HTTP ${response.status}`);
}
const responseText = await response.text();
// Remove XSSI prefix
const jsonText = responseText.replace(/^\)]}'/, '');
const cls = JSON.parse(jsonText);
if (!Array.isArray(cls)) {
throw new Error('Unexpected response format from PDFium Gerrit API');
}
return cls;
} catch (error: any) {
throw new GerritAPIError(`Failed to list PDFium Gerrit CLs: ${error.message}`, undefined, error);
}
}
/**
* Get bot errors from a Gerrit CL
*/
async getGerritCLBotErrors(params: { clNumber: string; patchset?: number; failedOnly?: boolean; botFilter?: string }): Promise<any> {
try {
// First, get the bot status
const botStatus = await this.getGerritCLTrybotStatus(params);
if (!botStatus.bots || botStatus.bots.length === 0) {
return {
clId: botStatus.clId,
patchset: botStatus.patchset,
message: 'No bots found for this CL',
bots: []
};
}
// Filter for failed bots only if requested
let botsToCheck = params.failedOnly !== false
? botStatus.bots.filter((bot: any) => bot.status === 'FAILED')
: botStatus.bots;
// Apply bot name filter if provided
if (params.botFilter) {
const filterLower = params.botFilter.toLowerCase();
const originalCount = botsToCheck.length;
botsToCheck = botsToCheck.filter((bot: any) =>
bot.name.toLowerCase().includes(filterLower)
);
this.debug(`[DEBUG] Bot filter "${params.botFilter}" matched ${botsToCheck.length}/${originalCount} bots`);
if (botsToCheck.length === 0) {
return {
clId: botStatus.clId,
patchset: botStatus.patchset,
message: `No bots matching filter "${params.botFilter}" found`,
bots: []
};
}
}
this.debug(`[DEBUG] Fetching errors for ${botsToCheck.length} bots`);
// Fetch errors for each bot
const botErrors = [];
for (const bot of botsToCheck) {
if (!bot.buildUrl) {
this.debug(`[DEBUG] No build URL for bot: ${bot.name}`);
botErrors.push({
botName: bot.name,
status: bot.status,
buildUrl: null,
error: 'No build URL available',
errors: null
});
continue;
}
try {
this.debug(`[DEBUG] Fetching errors for bot: ${bot.name}`);
const errors = await this.getCIBuildErrors(bot.buildUrl);
botErrors.push({
botName: bot.name,
status: bot.status,
buildUrl: bot.buildUrl,
errors: errors
});
} catch (error: any) {
this.debug(`[DEBUG] Failed to fetch errors for ${bot.name}: ${error.message}`);
botErrors.push({
botName: bot.name,
status: bot.status,
buildUrl: bot.buildUrl,
error: error.message,
errors: null
});
}
}
return {
clId: botStatus.clId,
patchset: botStatus.patchset,
luciUrl: botStatus.luciUrl,
totalBots: botStatus.totalBots,
failedBots: botStatus.failedBots,
botsWithErrors: botErrors.length,
bots: botErrors
};
} catch (error: any) {
this.debug('[DEBUG] Error fetching bot errors:', error);
throw new ChromiumSearchError(`Failed to fetch bot errors: ${error.message}`, error);
}
}
/**
* Parse CI build URL to extract project, bucket, builder, and build number
*/
private parseCIBuildUrl(url: string): { project: string; bucket: string; builder: string; buildNumber: string } | null {
// Format: https://ci.chromium.org/ui/p/{project}/builders/{bucket}/{builder}/{buildNumber}/...
const match = url.match(/\/p\/([^\/]+)\/builders\/([^\/]+)\/([^\/]+)\/(\d+)/);
if (!match) {
return null;
}
return {
project: match[1],
bucket: match[2],
builder: match[3],
buildNumber: match[4]
};
}
/**
* Fetch detailed test snippet (including stack traces) from ResultDB
*/
private async fetchTestSnippet(resultName: string): Promise<string | null> {
try {
this.debug(`[DEBUG] Fetching snippet for result: ${resultName}`);
// resultName format: invocations/{invocation}/tests/{test_id}/results/{result_id}
// We need to extract the invocation and test ID to call ListArtifacts
const nameMatch = resultName.match(/invocations\/([^\/]+)\/tests\/([^\/]+)\/results\/([^\/]+)/);
if (!nameMatch) {
this.debug(`[DEBUG] Could not parse result name: ${resultName}`);
return null;
}
const invocationId = nameMatch[1];
const testId = decodeURIComponent(nameMatch[2]);
const resultId = nameMatch[3];
// Call ListArtifacts to find the snippet artifact
const artifactsUrl = 'https://results.api.cr.dev/prpc/luci.resultdb.v1.ResultDB/ListArtifacts';
const artifactsRequest = {
parent: resultName,
pageSize: 100
};
this.debug(`[DEBUG] Calling ListArtifacts for ${resultName}`);
const artifactsResponse = await fetch(artifactsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(artifactsRequest)
});
if (!artifactsResponse.ok) {
this.debug(`[DEBUG] ListArtifacts failed with status ${artifactsResponse.status}`);
return null;
}
const artifactsText = await artifactsResponse.text();
const artifactsJson = artifactsText.startsWith(')]}\'') ? artifactsText.substring(4) : artifactsText;
const artifactsData = JSON.parse(artifactsJson);
// Look for the "snippet" artifact
const snippetArtifact = (artifactsData.artifacts || []).find((art: any) =>
art.artifactId === 'snippet' || art.name?.includes('/snippet')
);
if (!snippetArtifact) {
this.debug(`[DEBUG] No snippet artifact found for ${resultName}`);
return null;
}
// Fetch the snippet content
const snippetUrl = snippetArtifact.fetchUrl;
if (!snippetUrl) {
this.debug(`[DEBUG] No fetchUrl found for snippet artifact`);
return null;
}
this.debug(`[DEBUG] Fetching snippet content from ${snippetUrl}`);
const snippetResponse = await fetch(snippetUrl);
if (!snippetResponse.ok) {
this.debug(`[DEBUG] Failed to fetch snippet content: ${snippetResponse.status}`);
return null;
}
const snippetContent = await snippetResponse.text();
this.debug(`[DEBUG] Successfully fetched snippet (${snippetContent.length} bytes)`);
return snippetContent;
} catch (error: any) {
this.debug(`[DEBUG] Error fetching snippet: ${error.message}`);
return null;
}
}
/**
* Get build errors from CI Chromium build URL or Build ID
*/
async getCIBuildErrors(buildUrlOrNumber: string): Promise<any> {
try {
let buildId: string | undefined;
let project = 'chromium';
let bucket = 'try';
let builder = '';
let buildNumber = '';
// Check if it's a Buildbucket URL format (https://cr-buildbucket.appspot.com/build/BUILDID)
const buildbucketMatch = buildUrlOrNumber.match(/\/build\/(\d+)/);
if (buildbucketMatch) {
buildId = buildbucketMatch[1];
this.debug(`[DEBUG] Extracted build ID from Buildbucket URL: ${buildId}`);
} else if (buildUrlOrNumber.startsWith('http')) {
// Try to parse as CI URL format
const parsed = this.parseCIBuildUrl(buildUrlOrNumber);
if (!parsed) {
throw new Error('Invalid CI build URL format');
}
project = parsed.project;
bucket = parsed.bucket;
builder = parsed.builder;
buildNumber = parsed.buildNumber;
} else if (/^\d+$/.test(buildUrlOrNumber)) {
// It's just a build ID number
buildId = buildUrlOrNumber;
this.debug(`[DEBUG] Using build ID: ${buildId}`);
} else {
throw new Error('Please provide a valid CI build URL or build ID');
}
this.debug(`[DEBUG] Fetching build with ID: ${buildId} or ${project}/${bucket}/${builder}/${buildNumber}`);
// Step 1: Get build details from Buildbucket API
const buildApiUrl = 'https://cr-buildbucket.appspot.com/prpc/buildbucket.v2.Builds/GetBuild';
const buildRequest: any = buildId
? { id: buildId, fields: 'id,number,builder,status,infra.resultdb' }
: {
builder: { project, bucket, builder },
buildNumber: parseInt(buildNumber),
fields: 'id,number,builder,status,infra.resultdb'
};
this.debug('[DEBUG] Build request:', JSON.stringify(buildRequest));
const buildResponse = await fetch(buildApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Prpc-Grpc-Timeout': '10S'
},
body: JSON.stringify(buildRequest)
});
if (!buildResponse.ok) {
throw new Error(`Failed to fetch build details: HTTP ${buildResponse.status}`);
}
const buildText = await buildResponse.text();
// Remove XSSI prefix if present
const buildJson = buildText.replace(/^\)]}'/, '');
const buildData = JSON.parse(buildJson);
this.debug('[DEBUG] Build data received');
this.debug('[DEBUG] Build data:', JSON.stringify(buildData).substring(0, 500));
// Extract or construct invocation ID
let invocationId = buildData.infra?.resultdb?.invocation;
// If not in response, construct it from build ID
// Format: invocations/build-<build_id>
if (!invocationId && buildData.id) {
invocationId = `invocations/build-${buildData.id}`;
this.debug(`[DEBUG] Constructed invocation ID from build ID: ${invocationId}`);
}
if (!invocationId) {
return {
buildUrl: buildUrlOrNumber,
project: buildData.builder?.project || project,
bucket: buildData.builder?.bucket || bucket,
builder: buildData.builder?.builder || builder,
buildNumber: buildData.number || buildNumber,
status: buildData.status,
error: 'No test results available for this build (no invocation ID found)'
};
}
// Extract builder info from response if we used buildId
if (buildId && buildData.builder) {
project = buildData.builder.project || project;
bucket = buildData.builder.bucket || bucket;
builder = buildData.builder.builder || builder;
buildNumber = buildData.number?.toString() || buildNumber;
}
this.debug(`[DEBUG] Invocation ID: ${invocationId}`);
// Step 2: Query test failures from ResultDB API
const resultDbUrl = 'https://results.api.cr.dev/prpc/luci.resultdb.v1.ResultDB/QueryTestVariants';
const testRequest = {
invocations: [invocationId],
resultLimit: 100,
pageSize: 1000,
pageToken: '',
orderBy: 'status_v2_effective'
};
const testResponse = await fetch(resultDbUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(testRequest)
});
if (!testResponse.ok) {
throw new Error(`Failed to fetch test results: HTTP ${testResponse.status}`);
}
const testText = await testResponse.text();
// Remove XSSI prefix
const testJson = testText.replace(/^\)]}'/, '');
const testData = JSON.parse(testJson);
this.debug(`[DEBUG] Found ${testData.testVariants?.length || 0} test variants`);
// Filter for failed tests and extract error messages
const failedTests = await Promise.all(
(testData.testVariants || [])
.filter((variant: any) =>
variant.status === 'UNEXPECTED' ||
variant.statusV2 === 'FAILED'
)
.slice(0, 10) // Limit to first 10 failed tests to avoid too many API calls
.map(async (variant: any) => {
const results = variant.results || [];
const errorMessages = results
.filter((r: any) => r.result?.failureReason)
.map((r: any) => r.result.failureReason.primaryErrorMessage || 'No error message');
// Try to fetch the snippet artifact for detailed error output
let detailedError = null;
if (results.length > 0 && results[0].result?.name) {
try {
detailedError = await this.fetchTestSnippet(results[0].result.name);
} catch (error) {
this.debug(`[DEBUG] Could not fetch snippet for ${variant.testName}: ${error}`);
}
}
return {
testId: variant.testId,
testName: variant.testMetadata?.name || variant.testId,
status: variant.statusV2 || variant.status,
variant: variant.variant?.def || {},
errorMessages: errorMessages.length > 0 ? errorMessages : ['Test failed but no error message available'],
detailedError: detailedError,
location: variant.testMetadata?.location,
bugComponent: variant.testMetadata?.bugComponent
};
})
);
return {
buildUrl: buildUrlOrNumber,
project,
bucket,
builder,
buildNumber,
buildStatus: buildData.status,
invocationId,
totalTests: testData.testVariants?.length || 0,
failedTestCount: failedTests.length,
failedTests
};
} catch (error: any) {
this.debug('[DEBUG] Error fetching build errors:', error);
throw new ChromiumSearchError(`Failed to fetch CI build errors: ${error.message}`, error);
}
}
// ===== Gitiles Blame/History Commands =====
async getFileBlame(filePath: string, lineNumber?: number): Promise<any> {
try {
// Normalize file path
const normalizedPath = filePath.replace(/^\/+/, '');
const url = `https://chromium.googlesource.com/chromium/src/+blame/main/${normalizedPath}`;
this.debug(`[DEBUG] Fetching blame for ${normalizedPath}`);
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Accept': 'text/html'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const html = await response.text();
// Parse HTML to extract blame information
const { load } = await import('cheerio');
const $ = load(html);
const blameLines: any[] = [];
$('tr.Blame-region').each((index, element) => {
const $row = $(element);
const author = $row.find('.Blame-author').text().trim();
const sha1 = $row.find('.Blame-sha1').text().trim();
const time = $row.find('.Blame-time').text().trim();
const lineNum = parseInt($row.find('.Blame-lineNum a').text().trim());
const content = $row.find('.Blame-lineContent').text();
const sha1Link = $row.find('.Blame-sha1').attr('href');
blameLines.push({
lineNumber: lineNum,
author,
commit: sha1,
commitUrl: sha1Link ? `https://chromium.googlesource.com${sha1Link}` : null,
date: time,
content: content
});
// Also collect subsequent lines in the same region
let nextRow = $row.next();
while (nextRow.length && !nextRow.hasClass('Blame-region')) {
const nextLineNum = parseInt(nextRow.find('.Blame-lineNum a').text().trim());
const nextContent = nextRow.find('.Blame-lineContent').text();
if (nextLineNum) {
blameLines.push({
lineNumber: nextLineNum,
author,
commit: sha1,
commitUrl: sha1Link ? `https://chromium.googlesource.com${sha1Link}` : null,
date: time,
content: nextContent
});
}
nextRow = nextRow.next();
}
});
// Filter by line number if specified
const filteredLines = lineNumber
? blameLines.filter(line => line.lineNumber === lineNumber)
: blameLines;
return {
filePath: normalizedPath,
lineNumber,
totalLines: blameLines.length,
blameUrl: url,
lines: filteredLines
};
} catch (error) {
this.debug(`[ERROR] Blame fetch failed:`, error);
throw new ChromiumSearchError(
`Failed to fetch blame: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
);
}
}
async getFileHistory(filePath: string, limit: number = 20): Promise<any> {
try {
// Normalize file path
const normalizedPath = filePath.replace(/^\/+/, '');
const url = `https://chromium.googlesource.com/chromium/src/+log/main/${normalizedPath}`;
this.debug(`[DEBUG] Fetching history for ${normalizedPath}`);
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Accept': 'text/html'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const html = await response.text();
// Parse HTML to extract commit history
const { load } = await import('cheerio');
const $ = load(html);
const commits: any[] = [];
$('li.CommitLog-item').each((index, element) => {
if (commits.length >= limit) return false; // Stop after limit
const $item = $(element);
const sha1 = $item.find('.CommitLog-sha1').first().text().trim();
const message = $item.find('a[href*="/+/"]').first().text().trim();
const author = $item.find('.CommitLog-author').attr('title') ||
$item.find('.CommitLog-author').text().replace('by ', '').trim();
const time = $item.find('.CommitLog-time').attr('title') ||
$item.find('.CommitLog-time').text().replace('·', '').trim();
const commitUrl = $item.find('.CommitLog-sha1').first().attr('href');
commits.push({
commit: sha1,
message,
author,
date: time,
commitUrl: commitUrl ? `https://chromium.googlesource.com${commitUrl}` : null
});
});
return {
filePath: normalizedPath,
totalCommits: commits.length,
historyUrl: url,
commits
};
} catch (error) {
this.debug(`[ERROR] History fetch failed:`, error);
throw new ChromiumSearchError(
`Failed to fetch history: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
);
}
}
async getPathContributors(path: string, limit: number = 50): Promise<any> {
try {
// Get commit history for the path
const history = await this.getFileHistory(path, limit);
// Count contributions per author
const contributorMap = new Map<string, { count: number; lastCommit: string; lastDate: string }>();
for (const commit of history.commits) {
const author = commit.author;
if (!contributorMap.has(author)) {
contributorMap.set(author, {
count: 1,
lastCommit: commit.commit,
lastDate: commit.date
});
} else {
const contributor = contributorMap.get(author)!;
contributor.count++;
// Keep the most recent commit (history is sorted by date desc)
if (!contributor.lastDate || commit.date > contributor.lastDate) {
contributor.lastCommit = commit.commit;
contributor.lastDate = commit.date;
}
}
}
// Convert to array and sort by contribution count
const contributors = Array.from(contributorMap.entries())
.map(([author, stats]) => ({
author,
commits: stats.count,
lastCommit: stats.lastCommit,
lastCommitDate: stats.lastDate
}))
.sort((a, b) => b.commits - a.commits);
return {
path,
totalContributors: contributors.length,
analyzedCommits: history.totalCommits,
contributors
};
} catch (error) {
this.debug(`[ERROR] Contributors fetch failed:`, error);
throw new ChromiumSearchError(
`Failed to fetch contributors: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
);
}
}
// === REVIEWER SUGGESTION METHODS ===
/**
* Parse OWNERS file content into structured data
*/
private parseOwnersFileEnhanced(content: string, path: string): ParsedOwnersFile {
const result: ParsedOwnersFile = {
path,
directOwners: [],
perFileRules: [],
fileIncludes: [],
includeDirectives: [],
noParent: false,
hasWildcard: false,
};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
// Check for "set noparent"
if (trimmed === 'set noparent') {
result.noParent = true;
continue;
}
// Check for file:// references
if (trimmed.startsWith('file://')) {
result.fileIncludes.push(trimmed.substring(7));
continue;
}
// Check for include directives
if (trimmed.startsWith('include ')) {
result.includeDirectives.push(trimmed.substring(8).trim());
continue;
}
// Check for per-file rules: per-file *.ext=owner@chromium.org
if (trimmed.startsWith('per-file ')) {
const ruleContent = trimmed.substring(9);
const equalsIndex = ruleContent.indexOf('=');
if (equalsIndex > 0) {
const pattern = ruleContent.substring(0, equalsIndex).trim();
const ownersStr = ruleContent.substring(equalsIndex + 1).trim();
const owners = ownersStr.split(',')
.map(o => o.trim())
.filter(o => o.includes('@') || o === '*');
if (owners.length > 0) {
result.perFileRules.push({ pattern, owners });
}
}
continue;
}
// Check for wildcard
if (trimmed === '*') {
result.hasWildcard = true;
continue;
}
// Check for email addresses (direct owners)
if (trimmed.includes('@') && !trimmed.includes(' ')) {
result.directOwners.push(trimmed);
continue;
}
}
return result;
}
/**
* Check if a filename matches an OWNERS per-file pattern
*/
private matchesPerFilePattern(filename: string, pattern: string): boolean {
// Escape all regex special characters first, then convert glob patterns
const regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars (including +)
.replace(/\*/g, '.*') // Convert glob * to regex .*
.replace(/\?/g, '.'); // Convert glob ? to regex .
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(filename);
}
/**
* Get contributors to a file within a time period
*/
async getFileContributorsSince(
filePath: string,
sinceMonths: number = 6
): Promise<Map<string, { count: number; lastDate: string }>> {
const contributors = new Map<string, { count: number; lastDate: string }>();
try {
const normalizedPath = filePath.replace(/^\/+/, '');
const url = `https://chromium.googlesource.com/chromium/src/+log/main/${normalizedPath}?format=JSON&n=100`;
this.debug(`[DEBUG] Fetching contributors for ${normalizedPath}`);
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
});
if (!response.ok) {
this.debug(`[DEBUG] Failed to fetch history for ${filePath}: ${response.status}`);
return contributors;
}
const text = await response.text();
const jsonText = text.replace(/^\)\]\}'\n/, '');
const data = JSON.parse(jsonText);
if (!data.log) {
return contributors;
}
// Calculate cutoff date
const cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - sinceMonths);
for (const commit of data.log) {
const commitDate = new Date(commit.author.time);
if (commitDate < cutoffDate) {
continue;
}
const email = commit.author.email;
// Skip bot accounts
if (email.includes('gserviceaccount.com') ||
email.includes('appspot.com') ||
email.includes('nicotine')) {
continue;
}
if (!contributors.has(email)) {
contributors.set(email, {
count: 1,
lastDate: commit.author.time
});
} else {
const existing = contributors.get(email)!;
existing.count++;
if (new Date(commit.author.time) > new Date(existing.lastDate)) {
existing.lastDate = commit.author.time;
}
}
}
this.debug(`[DEBUG] Found ${contributors.size} contributors for ${filePath}`);
} catch (error) {
this.debug(`[DEBUG] Error fetching contributors for ${filePath}:`, error);
}
return contributors;
}
/**
* Suggest optimal reviewers for a CL based on OWNERS and activity
*/
async suggestReviewersForCL(params: SuggestReviewersParams): Promise<ReviewerSuggestion> {
const {
clNumber,
patchset,
maxReviewers = 5,
activityMonths = 6,
excludeReviewers = [],
fast = false
} = params;
const clId = this.extractCLNumber(clNumber);
// Get changed files from CL
const diffResult = await this.getGerritCLDiff({
clNumber: clId,
patchset
});
const changedFiles = diffResult.changedFiles.filter((f: string) => {
const fileInfo = diffResult.filesData[f];
return f !== '/COMMIT_MSG' && fileInfo?.status !== 'D';
});
// Get CL author to exclude
const clStatus = await this.getGerritCLStatus(clId);
const clAuthor = clStatus.owner?.email;
const excludeSet = new Set([
...excludeReviewers.map(e => e.toLowerCase()),
...(clAuthor ? [clAuthor.toLowerCase()] : [])
]);
const ownerCoverage = new Map<string, Set<string>>();
const ownerActivity = new Map<string, { totalCommits: number; lastDate: string | null }>();
const uncoveredFiles: string[] = [];
// OPTIMIZATION: Cache for OWNERS files to avoid re-fetching
const ownersCache = new Map<string, ParsedOwnersFile | null>();
let ownersFilesFetched = 0;
let commitHistoryFetched = 0;
// Helper to fetch and cache OWNERS file
const fetchOwners = async (ownersPath: string): Promise<ParsedOwnersFile | null> => {
if (ownersCache.has(ownersPath)) {
return ownersCache.get(ownersPath) || null;
}
try {
const rawUrl = `https://chromium.googlesource.com/chromium/src/+/main/${ownersPath}?format=TEXT`;
const response = await fetch(rawUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
});
if (!response.ok) {
ownersCache.set(ownersPath, null);
return null;
}
const base64Content = await response.text();
const content = Buffer.from(base64Content, 'base64').toString('utf-8');
const parsed = this.parseOwnersFileEnhanced(content, ownersPath);
ownersCache.set(ownersPath, parsed);
ownersFilesFetched++;
return parsed;
} catch (error) {
ownersCache.set(ownersPath, null);
return null;
}
};
// Helper to check if file is a test/expectation file (skip activity for these)
const isTestFile = (filePath: string): boolean => {
return filePath.includes('_test.') ||
filePath.includes('_test_') ||
filePath.includes('-expected') ||
filePath.includes('/test/') ||
filePath.includes('/tests/') ||
filePath.includes('_unittest') ||
filePath.includes('_browsertest') ||
filePath.includes('_apitest') ||
filePath.endsWith('.txt') ||
filePath.endsWith('.png') ||
filePath.endsWith('.json') ||
filePath.includes('web_tests/') ||
filePath.includes('test_data/') ||
filePath.includes('testdata/');
};
// OPTIMIZATION: Collect all unique OWNERS paths first, then fetch in parallel
const allOwnersPaths = new Set<string>();
const filesByDir = new Map<string, string[]>();
for (const filePath of changedFiles) {
const pathParts = filePath.split('/');
const dirPath = pathParts.slice(0, -1).join('/');
if (!filesByDir.has(dirPath)) {
filesByDir.set(dirPath, []);
}
filesByDir.get(dirPath)!.push(filePath);
// Collect all OWNERS paths we'll need
for (let i = pathParts.length - 1; i >= 0; i--) {
const ownersDirPath = pathParts.slice(0, i).join('/');
const ownersPath = ownersDirPath ? `${ownersDirPath}/OWNERS` : 'OWNERS';
allOwnersPaths.add(ownersPath);
}
}
this.debug(`[DEBUG] Processing ${changedFiles.length} files in ${filesByDir.size} directories`);
this.debug(`[DEBUG] Pre-fetching ${allOwnersPaths.size} OWNERS files in parallel...`);
// Fetch ALL OWNERS files in parallel (much faster!)
const ownerPathsArray = Array.from(allOwnersPaths);
await Promise.all(ownerPathsArray.map(p => fetchOwners(p)));
this.debug(`[DEBUG] OWNERS pre-fetch complete, processing files...`);
// Process files by directory (now using cached OWNERS)
for (const [dirPath, files] of filesByDir) {
const pathParts = dirPath.split('/').filter(p => p);
const directoryOwners: ParsedOwnersFile[] = [];
// All these are cached now, instant lookups
for (let i = pathParts.length; i >= 0; i--) {
const ownersDirPath = pathParts.slice(0, i).join('/');
const ownersPath = ownersDirPath ? `${ownersDirPath}/OWNERS` : 'OWNERS';
const parsed = await fetchOwners(ownersPath); // From cache
if (parsed) {
directoryOwners.push(parsed);
if (parsed.noParent) break;
}
}
// Process each file in this directory
for (const filePath of files) {
const fileName = filePath.split('/').pop() || '';
const applicableOwners = new Set<string>();
let foundOwners = false;
for (const parsed of directoryOwners) {
// Check per-file rules first
for (const rule of parsed.perFileRules) {
if (this.matchesPerFilePattern(fileName, rule.pattern)) {
for (const owner of rule.owners) {
if (owner !== '*' && !excludeSet.has(owner.toLowerCase())) {
applicableOwners.add(owner);
foundOwners = true;
}
}
}
}
// Add direct owners
for (const owner of parsed.directOwners) {
if (!excludeSet.has(owner.toLowerCase())) {
applicableOwners.add(owner);
foundOwners = true;
}
}
if (parsed.hasWildcard) {
foundOwners = true;
}
// Handle file:// includes (also pre-fetch these in parallel)
if (parsed.fileIncludes.length > 0) {
await Promise.all(parsed.fileIncludes.map(p => fetchOwners(p)));
for (const includePath of parsed.fileIncludes) {
const includeParsed = ownersCache.get(includePath);
if (includeParsed) {
for (const owner of includeParsed.directOwners) {
if (!excludeSet.has(owner.toLowerCase())) {
applicableOwners.add(owner);
foundOwners = true;
}
}
}
}
}
}
if (!foundOwners) {
uncoveredFiles.push(filePath);
}
// Update coverage map
for (const owner of applicableOwners) {
if (!ownerCoverage.has(owner)) {
ownerCoverage.set(owner, new Set());
}
ownerCoverage.get(owner)!.add(filePath);
}
}
}
// OPTIMIZATION: Only fetch activity for non-test source files, and limit to a sample
if (!fast) {
const sourceFiles = changedFiles.filter((f: string) => !isTestFile(f));
const filesToAnalyze = sourceFiles.slice(0, 5); // Limit to 5 source files max for speed
if (filesToAnalyze.length > 0) {
this.debug(`[DEBUG] Fetching activity for ${filesToAnalyze.length} source files in parallel...`);
// Fetch ALL activity in parallel
const results = await Promise.all(
filesToAnalyze.map((f: string) => this.getFileContributorsSince(f, activityMonths))
);
commitHistoryFetched = filesToAnalyze.length;
results.forEach((contributors) => {
for (const [email, stats] of contributors) {
if (excludeSet.has(email.toLowerCase())) continue;
if (!ownerActivity.has(email)) {
ownerActivity.set(email, { totalCommits: 0, lastDate: null });
}
const activity = ownerActivity.get(email)!;
activity.totalCommits += stats.count;
if (!activity.lastDate || new Date(stats.lastDate) > new Date(activity.lastDate)) {
activity.lastDate = stats.lastDate;
}
}
});
}
}
// Build candidates list
const allEmails = new Set([...ownerCoverage.keys()]);
const maxActivity = Math.max(1, ...Array.from(ownerActivity.values()).map(a => a.totalCommits));
const candidates: ReviewerCandidate[] = [];
for (const email of allEmails) {
const coverage = ownerCoverage.get(email);
const activity = ownerActivity.get(email);
if (!coverage || coverage.size === 0) {
continue;
}
const coverageScore = coverage.size / changedFiles.length;
const activityScore = fast ? 0 : (activity?.totalCommits || 0) / maxActivity;
const combinedScore = fast ? coverageScore : (0.6 * coverageScore + 0.4 * activityScore);
candidates.push({
email,
coverageScore,
activityScore,
combinedScore,
canReviewFiles: [...coverage],
recentCommits: activity?.totalCommits || 0,
lastCommitDate: activity?.lastDate || null,
});
}
// Sort by combined score
candidates.sort((a, b) => b.combinedScore - a.combinedScore);
// Greedy set cover for optimal reviewer set
const optimalSet: ReviewerCandidate[] = [];
const coveredFiles = new Set<string>();
while (coveredFiles.size < changedFiles.length && optimalSet.length < maxReviewers) {
let bestCandidate: ReviewerCandidate | null = null;
let bestNewCoverage = 0;
for (const candidate of candidates) {
if (optimalSet.includes(candidate)) continue;
const newCoverage = candidate.canReviewFiles.filter((f: string) => !coveredFiles.has(f)).length;
if (newCoverage > bestNewCoverage ||
(newCoverage === bestNewCoverage && bestCandidate &&
candidate.combinedScore > bestCandidate.combinedScore)) {
bestCandidate = candidate;
bestNewCoverage = newCoverage;
}
}
if (!bestCandidate || bestNewCoverage === 0) break;
optimalSet.push(bestCandidate);
for (const file of bestCandidate.canReviewFiles) {
coveredFiles.add(file);
}
}
return {
clNumber: clId,
subject: diffResult.subject,
changedFiles,
suggestedReviewers: candidates.slice(0, 20),
optimalSet,
uncoveredFiles,
analysisDetails: {
filesAnalyzed: changedFiles.length,
ownersFilesFetched,
commitHistoryFetched,
activityMonths: fast ? 0 : activityMonths,
},
};
}
}