Files
pikasTech-unidesk/scripts/analyze-github-merged-pr-emails.ts
T
2026-07-03 05:45:03 +00:00

381 lines
13 KiB
TypeScript

#!/usr/bin/env bun
type OutputFormat = "table" | "json" | "csv";
type Options = {
repo: string;
output: OutputFormat;
since: Date | null;
until: Date | null;
limit: number | null;
examples: number;
caseSensitive: boolean;
token: string | null;
};
type PullRecord = {
number: number;
title: string;
url: string;
mergedAt: string;
headOid: string | null;
email: string | null;
authorName: string | null;
authorLogin: string | null;
};
type EmailStat = {
email: string;
count: number;
prs: PullRecord[];
};
const defaultRepo = "pikasTech/PikaPython";
async function main(): Promise<void> {
const options = parseArgs(Bun.argv.slice(2));
const repo = parseRepo(options.repo);
const pulls = await fetchMergedPulls(repo.owner, repo.name, options);
const filtered = pulls.filter((pr) => withinDateRange(pr.mergedAt, options.since, options.until));
const selected = options.limit === null ? filtered : filtered.slice(0, options.limit);
const stats = summarize(selected, options);
if (options.output === "json") {
printJson(options, selected, stats);
} else if (options.output === "csv") {
printCsv(selected, stats);
} else {
printTable(options, selected, stats);
}
}
function parseArgs(args: string[]): Options {
const options: Options = {
repo: defaultRepo,
output: "table",
since: null,
until: null,
limit: null,
examples: 6,
caseSensitive: false,
token: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
if (arg === "--repo") {
options.repo = requireValue(args, ++index, arg);
} else if (arg.startsWith("--repo=")) {
options.repo = arg.slice("--repo=".length);
} else if (arg === "--json") {
options.output = "json";
} else if (arg === "--csv") {
options.output = "csv";
} else if (arg === "--since") {
options.since = parseDateArg(requireValue(args, ++index, arg), arg);
} else if (arg.startsWith("--since=")) {
options.since = parseDateArg(arg.slice("--since=".length), "--since");
} else if (arg === "--until") {
options.until = parseDateArg(requireValue(args, ++index, arg), arg);
} else if (arg.startsWith("--until=")) {
options.until = parseDateArg(arg.slice("--until=".length), "--until");
} else if (arg === "--limit") {
options.limit = parsePositiveInt(requireValue(args, ++index, arg), arg);
} else if (arg.startsWith("--limit=")) {
options.limit = parsePositiveInt(arg.slice("--limit=".length), "--limit");
} else if (arg === "--examples") {
options.examples = parsePositiveInt(requireValue(args, ++index, arg), arg);
} else if (arg.startsWith("--examples=")) {
options.examples = parsePositiveInt(arg.slice("--examples=".length), "--examples");
} else if (arg === "--case-sensitive") {
options.caseSensitive = true;
} else if (arg === "--token") {
options.token = requireValue(args, ++index, arg);
} else if (arg.startsWith("--token=")) {
options.token = arg.slice("--token=".length);
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
return options;
}
function printHelp(): void {
console.log(`Analyze merged GitHub PR counts by head commit author email.
Usage:
bun scripts/analyze-github-merged-pr-emails.ts [options]
Options:
--repo owner/name GitHub repo. Default: ${defaultRepo}
--since YYYY-MM-DD Include PRs merged at or after this UTC date.
--until YYYY-MM-DD Include PRs merged before or at this UTC date.
--limit N Limit merged PR records after date filtering.
--examples N Number of example PR numbers in table output. Default: 6
--json Print machine-readable JSON.
--csv Print CSV.
--case-sensitive Do not lowercase emails before grouping.
--token TOKEN GitHub token. Defaults to GITHUB_TOKEN or GH_TOKEN.
Definition:
A real PR is a pull request whose GitHub state is MERGED.
Each merged PR is counted once, using the author email of the PR head commit.
`);
}
function requireValue(args: string[], index: number, flag: string): string {
const value = args[index];
if (value === undefined || value.startsWith("--")) {
throw new Error(`${flag} requires a value`);
}
return value;
}
function parseDateArg(value: string, flag: string): Date {
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : value;
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
throw new Error(`${flag} must be a valid date, got: ${value}`);
}
return parsed;
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`${flag} must be a positive integer, got: ${value}`);
}
return parsed;
}
function parseRepo(repo: string): { owner: string; name: string } {
const cleaned = repo.replace(/^https:\/\/github\.com\//, "").replace(/\.git$/, "");
const parts = cleaned.split("/");
if (parts.length !== 2 || parts[0] === "" || parts[1] === "") {
throw new Error(`repo must be owner/name or github.com URL, got: ${repo}`);
}
return { owner: parts[0], name: parts[1] };
}
async function fetchMergedPulls(owner: string, name: string, options: Options): Promise<PullRecord[]> {
if (options.token === null || options.token.trim() === "") {
throw new Error("GitHub GraphQL requires a token; set GITHUB_TOKEN/GH_TOKEN or pass --token");
}
const query = `
query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(states: MERGED, first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) {
nodes {
number
title
url
mergedAt
commits(last: 1) {
nodes {
commit {
oid
author {
name
email
user { login }
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
rateLimit {
remaining
resetAt
}
}`;
const pulls: PullRecord[] = [];
let cursor: string | null = null;
while (true) {
const payload = await githubGraphql(options.token, query, { owner, name, cursor });
const repository = asRecord(payload.data)?.repository;
const pullRequests = asRecord(repository)?.pullRequests;
const nodes = asArray(asRecord(pullRequests)?.nodes);
for (const node of nodes) {
const pr = parsePullNode(node);
if (pr !== null) pulls.push(pr);
}
const pageInfo = asRecord(asRecord(pullRequests)?.pageInfo);
const hasNextPage = pageInfo?.hasNextPage === true;
cursor = typeof pageInfo?.endCursor === "string" ? pageInfo.endCursor : null;
if (!hasNextPage || cursor === null) break;
}
return pulls;
}
async function githubGraphql(token: string, query: string, variables: Record<string, unknown>): Promise<Record<string, unknown>> {
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "unidesk-pr-email-stats",
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify({ query, variables }),
});
const body = await response.text();
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
throw new Error(`GitHub GraphQL returned non-JSON status=${response.status}: ${body.slice(0, 500)}`);
}
const record = asRecord(parsed);
if (record === null) {
throw new Error("GitHub GraphQL returned a non-object response");
}
if (!response.ok || record.errors !== undefined) {
throw new Error(`GitHub GraphQL failed status=${response.status}: ${JSON.stringify(record.errors ?? record)}`);
}
return record;
}
function parsePullNode(node: unknown): PullRecord | null {
const record = asRecord(node);
if (record === null) return null;
const number = typeof record.number === "number" ? record.number : null;
const title = typeof record.title === "string" ? record.title : "";
const url = typeof record.url === "string" ? record.url : "";
const mergedAt = typeof record.mergedAt === "string" ? record.mergedAt : null;
const commitNode = asArray(asRecord(record.commits)?.nodes)[0];
const commit = asRecord(asRecord(commitNode)?.commit);
const author = asRecord(commit?.author);
if (number === null || mergedAt === null) return null;
return {
number,
title,
url,
mergedAt,
headOid: typeof commit?.oid === "string" ? commit.oid : null,
email: typeof author?.email === "string" && author.email.trim() !== "" ? author.email : null,
authorName: typeof author?.name === "string" && author.name.trim() !== "" ? author.name : null,
authorLogin: typeof asRecord(author?.user)?.login === "string" ? String(asRecord(author?.user)?.login) : null,
};
}
function withinDateRange(value: string, since: Date | null, until: Date | null): boolean {
const time = Date.parse(value);
if (Number.isNaN(time)) return false;
if (since !== null && time < since.getTime()) return false;
if (until !== null && time > until.getTime()) return false;
return true;
}
function summarize(pulls: PullRecord[], options: Options): EmailStat[] {
const byEmail = new Map<string, PullRecord[]>();
for (const pr of pulls) {
const rawEmail = pr.email ?? "(unknown)";
const email = options.caseSensitive ? rawEmail : rawEmail.toLowerCase();
const list = byEmail.get(email) ?? [];
list.push(pr);
byEmail.set(email, list);
}
return [...byEmail.entries()]
.map(([email, prs]) => ({ email, count: prs.length, prs }))
.sort((left, right) => right.count - left.count || left.email.localeCompare(right.email));
}
function printTable(options: Options, pulls: PullRecord[], stats: EmailStat[]): void {
console.log(`MERGED_PR_EMAIL_STATS repo=${options.repo} source=head-commit-author total=${pulls.length}`);
if (options.since !== null || options.until !== null) {
console.log(`RANGE since=${options.since?.toISOString() ?? "-"} until=${options.until?.toISOString() ?? "-"}`);
}
console.log("");
const rows = stats.map((stat) => ({
EMAIL: stat.email,
PRS: String(stat.count),
PERCENT: pulls.length === 0 ? "0.0%" : `${((stat.count / pulls.length) * 100).toFixed(1)}%`,
EXAMPLES: stat.prs.slice(0, options.examples).map((pr) => `#${pr.number}`).join(","),
}));
printRows(rows, ["EMAIL", "PRS", "PERCENT", "EXAMPLES"]);
console.log("");
console.log("Definition: merged PRs only; one PR counts once under the author email of its head commit.");
}
function printRows(rows: Array<Record<string, string>>, columns: string[]): void {
const widths = new Map<string, number>();
for (const column of columns) {
widths.set(column, Math.max(column.length, ...rows.map((row) => row[column]?.length ?? 0)));
}
const line = (row: Record<string, string>): string => columns
.map((column) => (row[column] ?? "").padEnd(widths.get(column) ?? column.length))
.join(" ")
.trimEnd();
console.log(line(Object.fromEntries(columns.map((column) => [column, column]))));
console.log(columns.map((column) => "-".repeat(widths.get(column) ?? column.length)).join(" "));
for (const row of rows) console.log(line(row));
}
function printJson(options: Options, pulls: PullRecord[], stats: EmailStat[]): void {
console.log(JSON.stringify({
repo: options.repo,
source: "head-commit-author",
totalMergedPullRequests: pulls.length,
since: options.since?.toISOString() ?? null,
until: options.until?.toISOString() ?? null,
stats: stats.map((stat) => ({
email: stat.email,
count: stat.count,
percent: pulls.length === 0 ? 0 : stat.count / pulls.length,
pullRequests: stat.prs.map((pr) => ({
number: pr.number,
mergedAt: pr.mergedAt,
headOid: pr.headOid,
authorName: pr.authorName,
authorLogin: pr.authorLogin,
title: pr.title,
url: pr.url,
})),
})),
}, null, 2));
}
function printCsv(pulls: PullRecord[], stats: EmailStat[]): void {
console.log(["email", "count", "percent", "pull_requests"].join(","));
for (const stat of stats) {
const percent = pulls.length === 0 ? "0" : String(stat.count / pulls.length);
const prs = stat.prs.map((pr) => `#${pr.number}`).join(" ");
console.log([csv(stat.email), stat.count, percent, csv(prs)].join(","));
}
}
function csv(value: string): string {
if (!/[",\n]/.test(value)) return value;
return `"${value.replaceAll("\"", "\"\"")}"`;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`error: ${message}`);
process.exit(1);
});