From a681246b1f94b606135d178df4ae9c35bc91c5ad Mon Sep 17 00:00:00 2001 From: "ellipsis-dev[bot]" <65095814+ellipsis-dev[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:27:45 +0000 Subject: [PATCH] Add agent analytics commands (reviewers, prs, reviews) over /v1/analytics/* --- README.md | 4 + src/cli.tsx | 2 + src/commands/analytics.ts | 266 ++++++++++++++++++++++++++++++++++++++ src/lib/api.ts | 44 +++++++ src/lib/types.ts | 182 ++++++++++++++++++++++++++ 5 files changed, 498 insertions(+) create mode 100644 src/commands/analytics.ts diff --git a/README.md b/README.md index 1d1134f..fdef115 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ agent sandbox variable rm K # delete a variable agent budget # current budget summary agent usage # usage dashboard for the period + +agent analytics reviewers --account-type bot # which apps review the most PRs +agent analytics prs --days 30 # PR volume/trend with human vs bot splits +agent analytics reviews --repo my-service # review totals + top reviewers agent ping # check authenticated /v1 connectivity ``` diff --git a/src/cli.tsx b/src/cli.tsx index 69e987d..61e0fb5 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -7,6 +7,7 @@ import { registerSandbox } from './commands/sandbox' import { registerHooks } from './commands/hooks' import { registerTemplate } from './commands/template' import { registerUsage } from './commands/usage' +import { registerAnalytics } from './commands/analytics' import { registerPing } from './commands/ping' import { VERSION } from './lib/constants' @@ -25,6 +26,7 @@ registerSandbox(program) registerHooks(program) registerTemplate(program) registerUsage(program) +registerAnalytics(program) registerPing(program) await program.parseAsync(process.argv) diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts new file mode 100644 index 0000000..f09a46d --- /dev/null +++ b/src/commands/analytics.ts @@ -0,0 +1,266 @@ +import type { Command } from 'commander' +import { InvalidArgumentError } from 'commander' +import { ApiClient } from '../lib/api' +import { collect, toInt } from '../lib/args' +import { printJson, printTable, runAction } from '../lib/output' +import type { + AnalyticsAccountType, + AnalyticsWindowQuery, + ReviewerUsage, +} from '../lib/types' + +// `agent analytics` — GitHub PR + review analytics over GET /v1/analytics/*: +// the same aggregation behind the app's /analytics dashboard, so questions +// like "which apps review the most PRs?" are answerable from the terminal +// (`agent analytics reviewers --account-type bot`). Human-readable tables by +// default; --json prints the raw API response for agents/scripts. + +// Window flags shared by every subcommand. The server defaults to the last +// 30 days; `--days` is mutually exclusive with `--start`. +interface WindowOpts { + days?: number + start?: string + end?: string +} + +function windowQuery(opts: WindowOpts): AnalyticsWindowQuery { + return { days: opts.days, start: opts.start, end: opts.end } +} + +function addWindowOptions(cmd: Command): Command { + return cmd + .option('-d, --days ', 'look back N days (default: 30)', toInt) + .option('--start ', 'window start (ISO timestamp; excludes --days)') + .option('--end ', 'window end (ISO timestamp; default: now)') +} + +function toAccountType(value: string): AnalyticsAccountType { + if (value === 'all' || value === 'user' || value === 'bot') return value + throw new InvalidArgumentError(`expected all, user, or bot; got "${value}"`) +} + +const REVIEWER_SORT_KEYS = { + reviews: (r: ReviewerUsage) => r.reviews, + approved: (r: ReviewerUsage) => r.approved, + 'changes-requested': (r: ReviewerUsage) => r.changes_requested, + comments: (r: ReviewerUsage) => r.comments, + lines: (r: ReviewerUsage) => r.lines_reviewed, +} as const + +type ReviewerSort = keyof typeof REVIEWER_SORT_KEYS + +function toReviewerSort(value: string): ReviewerSort { + if (value in REVIEWER_SORT_KEYS) return value as ReviewerSort + throw new InvalidArgumentError( + `expected one of ${Object.keys(REVIEWER_SORT_KEYS).join(', ')}; got "${value}"`, + ) +} + +export function registerAnalytics(program: Command): void { + const analytics = program + .command('analytics') + .description( + 'GitHub PR + review analytics for your org (GET /v1/analytics/*)', + ) + + addWindowOptions( + analytics + .command('reviewers') + .description( + 'Who reviewed the most PRs — people and apps (e.g. --account-type bot for apps only)', + ), + ) + .option( + '-r, --repo ', + 'filter by repository (repeatable)', + collect, + [] as string[], + ) + .option( + '--account-type ', + 'all | user (humans) | bot (apps/agents)', + toAccountType, + 'all' as AnalyticsAccountType, + ) + .option( + '--sort ', + `sort by ${Object.keys(REVIEWER_SORT_KEYS).join(' | ')}`, + toReviewerSort, + 'reviews' as ReviewerSort, + ) + .option('-l, --limit ', 'max reviewers to show', toInt, 10) + .option('--json', 'output raw JSON (the sorted reviewer list)') + .action( + async (opts: WindowOpts & { + repo: string[] + accountType: AnalyticsAccountType + sort: ReviewerSort + limit: number + json?: boolean + }) => { + await runAction(async () => { + const res = await new ApiClient().getAnalyticsMetrics({ + ...windowQuery(opts), + repo: opts.repo.length > 0 ? opts.repo : undefined, + account_type: opts.accountType, + }) + const key = REVIEWER_SORT_KEYS[opts.sort] + const reviewers = [...res.reviewers] + .sort((a, b) => key(b) - key(a)) + .slice(0, opts.limit) + if (opts.json) { + printJson(reviewers) + return + } + if (reviewers.length === 0) { + console.log('no reviews in this window') + return + } + printTable( + ['reviewer', 'reviews', 'approved', 'changes req', 'comments', 'lines reviewed'], + reviewers.map((r) => [ + r.login, + String(r.reviews), + String(r.approved), + String(r.changes_requested), + String(r.comments), + r.lines_reviewed.toLocaleString(), + ]), + ) + }) + }, + ) + + addWindowOptions( + analytics + .command('prs') + .description('Pull-request volume and trend, with human vs bot splits'), + ) + .option( + '--account-type ', + 'all | user (humans) | bot (apps/agents)', + toAccountType, + 'all' as AnalyticsAccountType, + ) + .option( + '--status ', + 'filter by PR status: open | draft | merged | closed (repeatable)', + collect, + [] as string[], + ) + .option('--json', 'output raw JSON (totals, per-day series, facets, recent PRs)') + .action( + async (opts: WindowOpts & { + accountType: AnalyticsAccountType + status: string[] + json?: boolean + }) => { + await runAction(async () => { + // The pull-requests endpoint filters by raw GitHub account types + // ("User"/"Bot"), unlike the all|user|bot enum elsewhere; map here so + // the CLI flag reads the same across subcommands. + const accountTypes = + opts.accountType === 'user' + ? ['User'] + : opts.accountType === 'bot' + ? ['Bot'] + : undefined + const res = await new ApiClient().getAnalyticsPullRequests({ + ...windowQuery(opts), + account_type: accountTypes, + status: opts.status.length > 0 ? opts.status : undefined, + }) + if (opts.json) { + printJson(res) + return + } + const t = res.totals + const sum = (pick: (d: (typeof res.series)[number]) => number) => + res.series.reduce((acc, d) => acc + pick(d), 0) + console.log(`prs opened: ${t.prs.toLocaleString()} (${sum((d) => d.prs_human).toLocaleString()} human, ${sum((d) => d.prs_bot).toLocaleString()} bot)`) + console.log(`prs merged: ${t.merged.toLocaleString()}`) + console.log(`lines changed: ${t.lines.toLocaleString()}`) + console.log(`commits: ${t.commits.toLocaleString()}`) + console.log(`active authors: ${t.active_authors.toLocaleString()}`) + console.log(`merge time p50: ${t.merge_time_p50_hours.toFixed(1)}h`) + if (res.truncated) { + console.log('note: window hit the server scan cap; figures undercount') + } + }) + }, + ) + + addWindowOptions( + analytics + .command('reviews') + .description('Review activity: totals, verdicts, and the top reviewers'), + ) + .option( + '-r, --repo ', + 'filter by repository name (bare name, repeatable)', + collect, + [] as string[], + ) + .option( + '--author ', + 'filter by reviewer login (repeatable)', + collect, + [] as string[], + ) + .option( + '--account-type ', + 'all | user (humans) | bot (apps/agents)', + toAccountType, + 'all' as AnalyticsAccountType, + ) + .option( + '--review-state ', + 'filter by verdict: APPROVED | CHANGES_REQUESTED | COMMENTED (repeatable)', + collect, + [] as string[], + ) + .option('-l, --limit ', 'max reviewers to show', toInt, 10) + .option('--json', 'output raw JSON (feeds, per-day series, totals, facets)') + .action( + async (opts: WindowOpts & { + repo: string[] + author: string[] + accountType: AnalyticsAccountType + reviewState: string[] + limit: number + json?: boolean + }) => { + await runAction(async () => { + const res = await new ApiClient().getAnalyticsReviews({ + ...windowQuery(opts), + repo: opts.repo.length > 0 ? opts.repo : undefined, + author: opts.author.length > 0 ? opts.author : undefined, + account_type: opts.accountType, + review_state: opts.reviewState.length > 0 ? opts.reviewState : undefined, + }) + if (opts.json) { + printJson(res) + return + } + const t = res.totals + console.log(`reviews: ${t.reviews.toLocaleString()}`) + console.log(`reviewers: ${t.reviewers.toLocaleString()}`) + console.log(`prs reviewed: ${t.prs.toLocaleString()}`) + console.log(`comments: ${t.comments.toLocaleString()} (${t.comments_human.toLocaleString()} human, ${t.comments_bot.toLocaleString()} bot)`) + console.log(`reactions: ${t.thumbs_up.toLocaleString()} 👍 ${t.thumbs_down.toLocaleString()} 👎`) + const authors = res.facets.authors.slice(0, opts.limit) + if (authors.length > 0) { + console.log('\ntop reviewers:') + printTable( + ['reviewer', 'type', 'reviews'], + authors.map((a) => [ + a.login, + a.account_type ?? '-', + String(a.reviews), + ]), + ) + } + }) + }, + ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 27f63a0..a7e39e7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3,11 +3,17 @@ import { USER_AGENT } from './constants' import type { AgentSession, AgentTemplate, + AnalyticsMetricsQuery, + AnalyticsPullRequestsQuery, + AnalyticsReviewsQuery, BudgetSummary, CliAuthPoll, CliAuthStart, CreateAgentConfigRequest, CreatedAgentConfig, + GetAnalyticsMetricsResponse, + GetAnalyticsPullRequestsResponse, + GetAnalyticsReviewsResponse, GetSandboxVariablesResponse, ListAgentConfigsResponse, ListAgentSessionsQuery, @@ -99,6 +105,44 @@ export class ApiClient { return this.request('GET', '/v1/usage') } + // ------------------------------- analytics ------------------------------ + // GitHub PR + review analytics — the same aggregation behind the app's + // /analytics dashboard, scoped to the token's customer. Window: pass + // start/end or days (server default: the last 30 days). + + getAnalyticsMetrics( + query?: AnalyticsMetricsQuery, + ): Promise { + return this.request( + 'GET', + '/v1/analytics/metrics', + undefined, + query as Record | undefined, + ) + } + + getAnalyticsPullRequests( + query?: AnalyticsPullRequestsQuery, + ): Promise { + return this.request( + 'GET', + '/v1/analytics/pull-requests', + undefined, + query as Record | undefined, + ) + } + + getAnalyticsReviews( + query?: AnalyticsReviewsQuery, + ): Promise { + return this.request( + 'GET', + '/v1/analytics/reviews', + undefined, + query as Record | undefined, + ) + } + // ---------------------------- agent sessions ----------------------------- startAgentSession(req: StartAgentSessionRequest): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 5835e5a..db42c2a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -286,6 +286,188 @@ export interface PutSandboxVariablesRequest { variables: SandboxVariableInput[] } +// ------------------------------- analytics ------------------------------- +// Mirrors of the /v1/analytics/* responses (analytics_service.py) — the same +// aggregation behind the app's /analytics dashboard, token-authed. The CLI +// renders the leaderboards and totals; feed items and day buckets it only +// passes through to --json are typed loosely. + +// Shared window params: explicit start/end (ISO timestamps) or a `days` +// look-back (mutually exclusive with start; server default: last 30 days). +export interface AnalyticsWindowQuery { + days?: number + start?: string + end?: string +} + +// all = everyone, user = humans only, bot = apps/agents only. +export type AnalyticsAccountType = 'all' | 'user' | 'bot' + +export interface AnalyticsMetricsQuery extends AnalyticsWindowQuery { + repo?: string[] // "owner/name" + author?: string[] // PR-author logins + account_type?: AnalyticsAccountType + status?: string[] // open | draft | merged | closed +} + +// A person or app that reviewed PRs in the window. +export interface ReviewerUsage { + login: string + avatar_url: string | null + reviews: number + approved: number + changes_requested: number + comments: number + lines_reviewed: number +} + +// An author who merged a PR in the window. +export interface ContributorUsage { + login: string + avatar_url: string | null + prs_merged: number + reviews: number + additions: number + deletions: number + ai_attributed_prs: number +} + +export interface AnalyticsRepoUsage { + repo_full_name: string + prs_merged: number + reviews: number + active_contributors: number + ai_attributed_prs: number + additions: number + deletions: number +} + +export interface AnalyticsMetricsTotals { + prs_opened: number + prs_merged: number + prs_closed: number + reviews: number + prs_reviewed: number + approved: number + changes_requested: number + commented: number + review_comments: number + additions: number + deletions: number + commits: number + active_contributors: number + open_prs: number + median_time_to_merge_hours: number + median_time_to_first_review_hours: number +} + +export interface GetAnalyticsMetricsResponse { + series: Array> + totals: AnalyticsMetricsTotals + repositories: AnalyticsRepoUsage[] + contributors: ContributorUsage[] + reviewers: ReviewerUsage[] + available_repos: Array<{ repo_full_name: string; prs: number }> + available_authors: Array<{ login: string; prs: number }> +} + +export interface AnalyticsPullRequestsQuery extends AnalyticsWindowQuery { + // Raw GithubAccountType strings ("User", "Bot"), unlike the metrics/reviews + // account_type enum — mirrors the backend filter. + account_type?: string[] + repository_id?: number[] + author_id?: number[] + status?: string[] +} + +export interface PullRequestsDayBucket { + date: string + prs: number + prs_human: number + prs_bot: number + merged: number + closed: number + lines: number + lines_human: number + lines_bot: number + commits: number + commits_human: number + commits_bot: number + authors: number + authors_human: number + authors_bot: number + // merge_time percentiles pass through untyped. + [key: string]: unknown +} + +export interface PullRequestsTotals { + prs: number + merged: number + lines: number + commits: number + active_authors: number + merge_time_p50_hours: number +} + +export interface GetAnalyticsPullRequestsResponse { + series: PullRequestsDayBucket[] + totals: PullRequestsTotals + facets: Record + recent: Array> + // True when the window hit the server's PR scan cap and figures undercount. + truncated: boolean +} + +export interface AnalyticsReviewsQuery extends AnalyticsWindowQuery { + repo?: string[] // bare repo names (matching the review facet values) + author?: string[] // reviewer logins + account_type?: AnalyticsAccountType + review_state?: string[] // APPROVED | CHANGES_REQUESTED | COMMENTED | ... +} + +export interface ReviewsDayBucket { + date: string + reviews: number + approved: number + commented: number + changes_requested: number + reviewers_human: number + reviewers_bot: number + reviewers_total: number + comments: number + comments_human: number + comments_bot: number +} + +export interface ReviewsTotals { + reviews: number + reviewers: number + prs: number + comments: number + comments_human: number + comments_bot: number + thumbs_up: number + thumbs_down: number +} + +export interface ReviewAuthorFacet { + login: string + avatar_url: string | null + account_type: string | null + reviews: number +} + +export interface GetAnalyticsReviewsResponse { + reviews: Array> + review_comments: Array> + series: ReviewsDayBucket[] + totals: ReviewsTotals + facets: { + repos: Array<{ repository_name: string; reviews: number }> + authors: ReviewAuthorFacet[] + } +} + // ------------------------------ cli auth -------------------------------- export interface CliAuthStart {