Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions src/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class GeminiClient {
return new Promise<void>(resolve => setTimeout(resolve, ms));
}

private async parseJson<T>(response: GenerateContentResponse): Promise<{ data: T; thoughts: string }> {
private async parseJson<T>(response: GenerateContentResponse): Promise<{ data: T; thoughts: string; inputTokens: number; outputTokens: number }> {
// Manually extract text from parts to avoid warnings about non-text parts (e.g., thoughtSignature)
// when using Gemini 3.0 models with thinking enabled
const thoughts: string[] = [];
Expand Down Expand Up @@ -79,7 +79,11 @@ export class GeminiClient {
.replace(/(\r?\n\s*){2,}/g, '\n')
.trim();

return { data, thoughts: collapsedThoughts };
// Extract token usage from response metadata
const inputTokens = response.usageMetadata?.promptTokenCount ?? 0;
const outputTokens = response.usageMetadata?.candidatesTokenCount ?? 0;

return { data, thoughts: collapsedThoughts, inputTokens, outputTokens };
} catch {
throw new GeminiResponseError('Unable to parse JSON from Gemini response');
}
Expand All @@ -89,7 +93,7 @@ export class GeminiClient {
payload: GenerateContentParameters,
maxRetries: number,
initialBackoffMs: number
): Promise<{ data: T; thoughts: string }> {
): Promise<{ data: T; thoughts: string; inputTokens: number; outputTokens: number }> {
let attempt = 0;
let lastError: unknown = undefined;
const totalAttempts = (maxRetries | 0) + 1;
Expand Down
20 changes: 20 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,20 @@ export type TimelineEvent = {

export class GitHubClient {
private octokit;
private apiCallCount = 0;

constructor(token: string, private owner: string, private repo: string) {
this.octokit = github.getOctokit(token);
}

getApiCallCount(): number {
return this.apiCallCount;
}

private incrementApiCalls(): void {
this.apiCallCount++;
}

private buildMetadata(rawIssue: any): Issue {
return {
title: rawIssue.title,
Expand All @@ -78,11 +88,13 @@ export class GitHubClient {
}

async getIssue(issue_number: number): Promise<Issue> {
this.incrementApiCalls();
const { data } = await this.octokit.rest.issues.get({ owner: this.owner, repo: this.repo, issue_number });
return this.buildMetadata(data);
}

async listOpenIssues(): Promise<Issue[]> {
this.incrementApiCalls();
const issues = await this.octokit.paginate(this.octokit.rest.issues.listForRepo, {
owner: this.owner,
repo: this.repo,
Expand All @@ -95,6 +107,7 @@ export class GitHubClient {
}

async listRepoLabels(): Promise<Array<{ name: string; description?: string | null }>> {
this.incrementApiCalls();
const labels = await this.octokit.paginate(this.octokit.rest.issues.listLabelsForRepo, {
owner: this.owner,
repo: this.repo,
Expand All @@ -113,6 +126,7 @@ export class GitHubClient {
}

async listTimelineEvents(issue_number: number, limit: number): Promise<{ raw: any[]; filtered: TimelineEvent[] }> {
this.incrementApiCalls();
const events = await this.octokit.paginate('GET /repos/{owner}/{repo}/issues/{issue_number}/timeline', {
owner: this.owner,
repo: this.repo,
Expand Down Expand Up @@ -176,25 +190,30 @@ export class GitHubClient {

async addLabels(issue_number: number, labels: string[]): Promise<void> {
if (labels.length === 0) return;
this.incrementApiCalls();
await this.octokit.rest.issues.addLabels({ owner: this.owner, repo: this.repo, issue_number, labels });
}

async removeLabel(issue_number: number, name: string): Promise<void> {
this.incrementApiCalls();
await this.octokit.rest.issues.removeLabel({ owner: this.owner, repo: this.repo, issue_number, name });
}

async createComment(issue_number: number, body: string): Promise<void> {
this.incrementApiCalls();
await this.octokit.rest.issues.createComment({ owner: this.owner, repo: this.repo, issue_number, body });
}

async updateTitle(issue_number: number, title: string): Promise<void> {
this.incrementApiCalls();
await this.octokit.rest.issues.update({ owner: this.owner, repo: this.repo, issue_number, title });
}

async closeIssue(
issue_number: number,
reason: 'completed' | 'not_planned' | 'reopened' | undefined = 'not_planned'
): Promise<void> {
this.incrementApiCalls();
await this.octokit.rest.issues.update({
owner: this.owner,
repo: this.repo,
Expand All @@ -209,6 +228,7 @@ export class GitHubClient {
state: 'open' | 'closed',
reason?: 'completed' | 'not_planned'
): Promise<void> {
this.incrementApiCalls();
await this.octokit.rest.issues.update({
owner: this.owner,
repo: this.repo,
Expand Down
41 changes: 37 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GitHubClient, Issue } from './github';
import { buildJsonPayload, GeminiClient, GeminiResponseError } from './gemini';
import { TriageOperation, planOperations } from './triage';
import { buildAutoDiscoverQueue } from './autoDiscover';
import { RunStatistics } from './stats';
import chalk from 'chalk';

chalk.level = 3;
Expand All @@ -14,6 +15,8 @@ const cfg = getConfig();
const db = loadDatabase(cfg.dbPath);
const gh = new GitHubClient(cfg.token, cfg.owner, cfg.repo);
const gemini = new GeminiClient(cfg.geminiApiKey);
const stats = new RunStatistics();
stats.setRepository(cfg.owner, cfg.repo);

async function run(): Promise<void> {
const repoLabels = await gh.listRepoLabels();
Expand Down Expand Up @@ -44,12 +47,18 @@ async function run(): Promise<void> {
try {
const issue = await gh.getIssue(n);
const { triageUsed, fastRunUsed } = await processIssue(issue, repoLabels, autoDiscover);
if (triageUsed) triagesPerformed++;
if (triageUsed) {
triagesPerformed++;
stats.incrementTriaged();
} else {
stats.incrementSkipped();
}
if (fastRunUsed) fastRunsPerformed++;
consecutiveFailures = 0; // reset on success path
} catch (err) {
if (err instanceof GeminiResponseError) {
console.warn(`#${n}: ${err.message}`);
stats.incrementFailed();
consecutiveFailures++;
if (consecutiveFailures >= 3) {
console.error(`Analysis failed ${consecutiveFailures} consecutive times; stopping further processing.`);
Expand All @@ -68,6 +77,10 @@ async function run(): Promise<void> {

saveDatabase(db, cfg.dbPath, cfg.enabled);
}

// Print summary at the end
stats.incrementGithubApiCalls(gh.getApiCallCount());
stats.printSummary();
}

run();
Expand Down Expand Up @@ -107,7 +120,8 @@ async function processIssue(
cfg.thinkingBudget,
systemPrompt,
userPrompt,
repoLabels
repoLabels,
true // isFastModel
);

fastRunUsed = true;
Expand All @@ -130,7 +144,8 @@ async function processIssue(
cfg.thinkingBudget,
systemPrompt,
userPrompt,
repoLabels
repoLabels,
false // isFastModel
);

if (proOps.length === 0) {
Expand All @@ -139,6 +154,12 @@ async function processIssue(
saveArtifact(issue.number, 'operations.json', JSON.stringify(proOps.map(o => o.toJSON()), null, 2));
for (const op of proOps) {
await op.perform(gh, cfg, issue);
// Track action details
stats.trackAction({
issueNumber: issue.number,
type: op.kind,
details: op.getActionDetails(),
});
}
}

Expand All @@ -155,6 +176,7 @@ export async function generateAnalysis(
systemPrompt: string,
userPrompt: string,
repoLabels: Array<{ name: string; description?: string | null }>,
isFastModel: boolean = false,
): Promise<{ data: AnalysisResult; thoughts: string, ops: TriageOperation[] }> {
const schema = buildAnalysisResultSchema(repoLabels);
const payload = buildJsonPayload(
Expand All @@ -167,7 +189,18 @@ export async function generateAnalysis(
);

console.log(chalk.blue(`πŸ’­ Thinking with ${model}...`));
const { data, thoughts } = await gemini.generateJson<AnalysisResult>(payload, 2, 5000);
const startTime = Date.now();
const { data, thoughts, inputTokens, outputTokens } = await gemini.generateJson<AnalysisResult>(payload, 2, 5000);
const endTime = Date.now();

// Track model run stats
const modelRunStats = { startTime, endTime, inputTokens, outputTokens };
if (isFastModel) {
stats.trackFastRun(modelRunStats);
} else {
stats.trackProRun(modelRunStats);
}

console.log(chalk.magenta(thoughts));
saveArtifact(issue.number, `${model}-analysis.json`, JSON.stringify(data, null, 2));
saveArtifact(issue.number, `${model}-thoughts.txt`, thoughts);
Expand Down
169 changes: 169 additions & 0 deletions src/stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import chalk from 'chalk';

export interface ModelRunStats {
startTime: number;
endTime: number;
inputTokens: number;
outputTokens: number;
}

export interface ActionDetail {
issueNumber: number;
type: 'labels' | 'comment' | 'title' | 'state';
details: string;
}

export class RunStatistics {
private fastRuns: ModelRunStats[] = [];
private proRuns: ModelRunStats[] = [];
private actionsPerformed: ActionDetail[] = [];
private triaged = 0;
private skipped = 0;
private failed = 0;
private githubApiCalls = 0;
private owner = '';
private repo = '';

setRepository(owner: string, repo: string): void {
this.owner = owner;
this.repo = repo;
}

trackFastRun(stats: ModelRunStats): void {
this.fastRuns.push(stats);
}

trackProRun(stats: ModelRunStats): void {
this.proRuns.push(stats);
}

trackAction(action: ActionDetail): void {
this.actionsPerformed.push(action);
}

incrementTriaged(): void {
this.triaged++;
}

incrementSkipped(): void {
this.skipped++;
}

incrementFailed(): void {
this.failed++;
}

incrementGithubApiCalls(count: number = 1): void {
this.githubApiCalls += count;
}

private formatDuration(ms: number): string {
if (ms < 1000) return `${ms.toFixed(0)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}m${seconds}s`;
}

private formatTokens(count: number): string {
if (count < 1000) return `${count}`;
if (count < 1000000) return `${(count / 1000).toFixed(1)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}

private calculateStats(runs: ModelRunStats[]): {
total: number;
avg: number;
p95: number;
inputTokens: number;
outputTokens: number;
} {
if (runs.length === 0) {
return { total: 0, avg: 0, p95: 0, inputTokens: 0, outputTokens: 0 };
}

const durations = runs.map(r => r.endTime - r.startTime);
const total = durations.reduce((sum, d) => sum + d, 0);
const avg = total / runs.length;
const sorted = [...durations].sort((a, b) => a - b);
const p95Index = Math.min(Math.floor(sorted.length * 0.95), sorted.length - 1);
const p95 = sorted[p95Index] ?? 0;
const inputTokens = runs.reduce((sum, r) => sum + r.inputTokens, 0);
const outputTokens = runs.reduce((sum, r) => sum + r.outputTokens, 0);

return { total, avg, p95, inputTokens, outputTokens };
}

printSummary(): void {
console.log('\n' + chalk.bold('πŸ“Š Run Statistics:'));

// Fast model stats
if (this.fastRuns.length > 0) {
const stats = this.calculateStats(this.fastRuns);
console.log(chalk.cyan(' Fast'));
console.log(
` Total: ${this.formatDuration(stats.total)} β€’ ` +
`Avg: ${this.formatDuration(stats.avg)} β€’ ` +
`p95: ${this.formatDuration(stats.p95)}`
);
console.log(
` Tokens used: ${this.formatTokens(stats.inputTokens)} input, ` +
`${this.formatTokens(stats.outputTokens)} output`
);
}

// Pro model stats
if (this.proRuns.length > 0) {
const stats = this.calculateStats(this.proRuns);
console.log(chalk.cyan(' Pro'));
console.log(
` Total: ${this.formatDuration(stats.total)} β€’ ` +
`Avg: ${this.formatDuration(stats.avg)} β€’ ` +
`p95: ${this.formatDuration(stats.p95)}`
);
console.log(
` Tokens used: ${this.formatTokens(stats.inputTokens)} input, ` +
`${this.formatTokens(stats.outputTokens)} output`
);
}

// Actions summary
const actionParts: string[] = [];
if (this.triaged > 0) actionParts.push(`βœ… ${this.triaged} triaged`);
if (this.skipped > 0) actionParts.push(`ℹ️ ${this.skipped} skipped`);
if (this.failed > 0) actionParts.push(`❌ ${this.failed} failed`);

if (actionParts.length > 0) {
console.log(` Actions performed: ${actionParts.join(', ')}`);
}

// GitHub API calls
if (this.githubApiCalls > 0) {
console.log(` GitHub API calls: ${this.githubApiCalls}`);
}

// Detailed actions list
if (this.actionsPerformed.length > 0) {
console.log('\n' + chalk.bold('πŸ“‹ Summary of Actions Performed:'));

// Group actions by issue number
const byIssue = new Map<number, ActionDetail[]>();
for (const action of this.actionsPerformed) {
if (!byIssue.has(action.issueNumber)) {
byIssue.set(action.issueNumber, []);
}
byIssue.get(action.issueNumber)!.push(action);
}

// Sort by issue number
const sortedIssues = Array.from(byIssue.keys()).sort((a, b) => a - b);

for (const issueNumber of sortedIssues) {
const actions = byIssue.get(issueNumber)!;
const parts = actions.map(a => a.details);
const prefix = this.owner && this.repo ? `${this.owner}/${this.repo}#${issueNumber}` : `#${issueNumber}`;
console.log(` ${prefix}: ${parts.join(', ')}`);
}
}
}
}
Loading