chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
async function getWorkflowMinutes(): Promise<Record<string, number>> {
|
||||
const sevenDaysAgoDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const output = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'run',
|
||||
'list',
|
||||
'--limit',
|
||||
'1000',
|
||||
'--created',
|
||||
`>=${sevenDaysAgoDate}`,
|
||||
'--json',
|
||||
'databaseId,workflowName',
|
||||
],
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
const runs = JSON.parse(output);
|
||||
const workflowMinutes: Record<string, number> = {};
|
||||
const token = execFileSync('gh', ['auth', 'token'], {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const repoInfo = JSON.parse(
|
||||
execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner'], {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
);
|
||||
const repoName = repoInfo.nameWithOwner;
|
||||
|
||||
const chunkSize = 20;
|
||||
for (let i = 0; i < runs.length; i += chunkSize) {
|
||||
const chunk = runs.slice(i, i + chunkSize);
|
||||
await Promise.all(
|
||||
chunk.map(async (r: { databaseId: number; workflowName?: string }) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${repoName}/actions/runs/${r.databaseId}/jobs`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const { jobs } = await res.json();
|
||||
let runBillableMinutes = 0;
|
||||
|
||||
for (const job of jobs || []) {
|
||||
if (!job.started_at || !job.completed_at) continue;
|
||||
const start = new Date(job.started_at).getTime();
|
||||
const end = new Date(job.completed_at).getTime();
|
||||
const durationMs = end - start;
|
||||
|
||||
if (durationMs > 0) {
|
||||
runBillableMinutes += Math.ceil(durationMs / (1000 * 60));
|
||||
}
|
||||
}
|
||||
|
||||
if (runBillableMinutes > 0) {
|
||||
const name = r.workflowName || 'Unknown';
|
||||
workflowMinutes[name] =
|
||||
(workflowMinutes[name] || 0) + runBillableMinutes;
|
||||
}
|
||||
} catch {
|
||||
// Ignore failures for individual runs
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return workflowMinutes;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const workflowMinutes = await getWorkflowMinutes();
|
||||
let totalMinutes = 0;
|
||||
|
||||
for (const minutes of Object.values(workflowMinutes)) {
|
||||
totalMinutes += minutes;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
metric: 'actions_spend_minutes',
|
||||
value: totalMinutes,
|
||||
timestamp: now,
|
||||
details: workflowMinutes,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [name, minutes] of Object.entries(workflowMinutes)) {
|
||||
const safeName = name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
metric: `actions_spend_minutes_workflow:${safeName}`,
|
||||
value: minutes,
|
||||
timestamp: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Calculates the average age of the oldest 100 open issues in days.
|
||||
*/
|
||||
function run() {
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(first: 100, states: OPEN, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(
|
||||
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
).trim();
|
||||
const data = JSON.parse(output).data.repository;
|
||||
const issues = data.issues.nodes;
|
||||
|
||||
if (issues.length === 0) {
|
||||
process.stdout.write('backlog_age_days,0\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().getTime();
|
||||
const totalAgeDays = issues.reduce(
|
||||
(acc: number, issue: { createdAt: string }) => {
|
||||
const created = new Date(issue.createdAt).getTime();
|
||||
return acc + (now - created) / (1000 * 60 * 60 * 24);
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
const avgAgeDays = totalAgeDays / issues.length;
|
||||
process.stdout.write(
|
||||
`backlog_age_days,${Math.round(avgAgeDays * 100) / 100}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @license
|
||||
*/
|
||||
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, '../../../../');
|
||||
|
||||
try {
|
||||
// 1. Fetch recent PR numbers and reviews from GitHub (so we have reviewer names/logins)
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(last: 100, states: MERGED) {
|
||||
nodes {
|
||||
number
|
||||
reviews(first: 20) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
author { login, ... on User { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(
|
||||
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
);
|
||||
const data = JSON.parse(output).data.repository;
|
||||
|
||||
// 2. Map PR numbers to local commits using git log
|
||||
const logOutput = execSync('git log -n 5000 --format="%H|%s"', {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const prCommits = new Map<number, string>();
|
||||
for (const line of logOutput.split('\n')) {
|
||||
if (!line) continue;
|
||||
const [hash, subject] = line.split('|');
|
||||
const match = subject.match(/\(#(\d+)\)$/);
|
||||
if (match) {
|
||||
prCommits.set(parseInt(match[1], 10), hash);
|
||||
}
|
||||
}
|
||||
|
||||
let totalMaintainerReviews = 0;
|
||||
let maintainerReviewsWithExpertise = 0;
|
||||
|
||||
// Cache git log authors per path to avoid redundant child_process calls
|
||||
const authorCache = new Map<string, string>();
|
||||
const getAuthors = (targetPath: string) => {
|
||||
if (authorCache.has(targetPath)) return authorCache.get(targetPath)!;
|
||||
try {
|
||||
const authors = execSync(
|
||||
`git log --format="%an|%ae" -- ${JSON.stringify(targetPath)}`,
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
},
|
||||
).toLowerCase();
|
||||
authorCache.set(targetPath, authors);
|
||||
return authors;
|
||||
} catch {
|
||||
authorCache.set(targetPath, '');
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
for (const pr of data.pullRequests.nodes) {
|
||||
if (!pr.reviews?.nodes || pr.reviews.nodes.length === 0) continue;
|
||||
|
||||
const commitHash = prCommits.get(pr.number);
|
||||
if (!commitHash) continue; // Skip if we don't have the commit locally
|
||||
|
||||
// 3. Get exact files changed using local git diff-tree, bypassing GraphQL limits
|
||||
const diffTreeOutput = execSync(
|
||||
`git diff-tree --no-commit-id --name-only -r ${commitHash}`,
|
||||
{ cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
);
|
||||
const files = diffTreeOutput.split('\n').filter(Boolean);
|
||||
if (files.length === 0) continue;
|
||||
|
||||
const reviewersOnPR = new Map<string, { name?: string }>();
|
||||
for (const review of pr.reviews.nodes) {
|
||||
if (
|
||||
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(
|
||||
review.authorAssociation,
|
||||
) &&
|
||||
review.author?.login
|
||||
) {
|
||||
const login = review.author.login.toLowerCase();
|
||||
if (login.endsWith('[bot]') || login.includes('bot')) continue;
|
||||
reviewersOnPR.set(login, review.author);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [login, authorInfo] of reviewersOnPR.entries()) {
|
||||
totalMaintainerReviews++;
|
||||
let hasExpertise = false;
|
||||
const name = authorInfo.name ? authorInfo.name.toLowerCase() : '';
|
||||
|
||||
for (const file of files) {
|
||||
// Precise check: immediate file
|
||||
let authorsStr = getAuthors(file);
|
||||
if (authorsStr.includes(login) || (name && authorsStr.includes(name))) {
|
||||
hasExpertise = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Fallback: file's directory
|
||||
const dir = path.dirname(file);
|
||||
authorsStr = getAuthors(dir);
|
||||
if (authorsStr.includes(login) || (name && authorsStr.includes(name))) {
|
||||
hasExpertise = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExpertise) {
|
||||
maintainerReviewsWithExpertise++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ratio =
|
||||
totalMaintainerReviews > 0
|
||||
? maintainerReviewsWithExpertise / totalMaintainerReviews
|
||||
: 0;
|
||||
|
||||
process.stdout.write(`domain_expertise,${Math.round(ratio * 100) / 100}\n`);
|
||||
} catch (err) {
|
||||
process.stderr.write(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @license
|
||||
*/
|
||||
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(last: 100, states: MERGED) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
createdAt
|
||||
mergedAt
|
||||
}
|
||||
}
|
||||
issues(last: 100, states: CLOSED) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
createdAt
|
||||
closedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(
|
||||
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
const data = JSON.parse(output).data.repository;
|
||||
|
||||
const prs = data.pullRequests.nodes.map(
|
||||
(p: {
|
||||
authorAssociation: string;
|
||||
mergedAt: string;
|
||||
createdAt: string;
|
||||
}) => ({
|
||||
association: p.authorAssociation,
|
||||
latencyHours:
|
||||
(new Date(p.mergedAt).getTime() - new Date(p.createdAt).getTime()) /
|
||||
(1000 * 60 * 60),
|
||||
}),
|
||||
);
|
||||
const issues = data.issues.nodes.map(
|
||||
(i: {
|
||||
authorAssociation: string;
|
||||
closedAt: string;
|
||||
createdAt: string;
|
||||
}) => ({
|
||||
association: i.authorAssociation,
|
||||
latencyHours:
|
||||
(new Date(i.closedAt).getTime() - new Date(i.createdAt).getTime()) /
|
||||
(1000 * 60 * 60),
|
||||
}),
|
||||
);
|
||||
|
||||
const isMaintainer = (assoc: string) =>
|
||||
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
|
||||
const calculateAvg = (
|
||||
items: { association: string; latencyHours: number }[],
|
||||
) =>
|
||||
items.length
|
||||
? items.reduce((a, b) => a + b.latencyHours, 0) / items.length
|
||||
: 0;
|
||||
|
||||
const prMaintainers = calculateAvg(
|
||||
prs.filter((i: { association: string; latencyHours: number }) =>
|
||||
isMaintainer(i.association),
|
||||
),
|
||||
);
|
||||
const prCommunity = calculateAvg(
|
||||
prs.filter(
|
||||
(i: { association: string; latencyHours: number }) =>
|
||||
!isMaintainer(i.association),
|
||||
),
|
||||
);
|
||||
const prOverall = calculateAvg(prs);
|
||||
|
||||
const issueMaintainers = calculateAvg(
|
||||
issues.filter((i: { association: string; latencyHours: number }) =>
|
||||
isMaintainer(i.association),
|
||||
),
|
||||
);
|
||||
const issueCommunity = calculateAvg(
|
||||
issues.filter(
|
||||
(i: { association: string; latencyHours: number }) =>
|
||||
!isMaintainer(i.association),
|
||||
),
|
||||
);
|
||||
const issueOverall = calculateAvg(issues);
|
||||
|
||||
process.stdout.write(
|
||||
`latency_pr_overall_hours,${Math.round(prOverall * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`latency_pr_maintainers_hours,${Math.round(prMaintainers * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`latency_pr_community_hours,${Math.round(prCommunity * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`latency_issue_overall_hours,${Math.round(issueOverall * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`latency_issue_maintainers_hours,${Math.round(issueMaintainers * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`latency_issue_community_hours,${Math.round(issueCommunity * 100) / 100}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @license
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(states: OPEN) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(
|
||||
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
).trim();
|
||||
const parsed = JSON.parse(output);
|
||||
const totalCount = parsed?.data?.repository?.issues?.totalCount ?? 0;
|
||||
process.stdout.write(`open_issues,${totalCount}\n`);
|
||||
} catch (err) {
|
||||
process.stderr.write(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @license
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(states: OPEN) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(
|
||||
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
).trim();
|
||||
const parsed = JSON.parse(output);
|
||||
const totalCount = parsed?.data?.repository?.pullRequests?.totalCount ?? 0;
|
||||
process.stdout.write(`open_prs,${totalCount}\n`);
|
||||
} catch (err) {
|
||||
process.stderr.write(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @license
|
||||
*/
|
||||
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(last: 100) {
|
||||
nodes {
|
||||
reviews(first: 50) {
|
||||
nodes {
|
||||
author { login }
|
||||
authorAssociation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(
|
||||
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
const data = JSON.parse(output).data.repository;
|
||||
|
||||
const reviewCounts: Record<string, number> = {};
|
||||
|
||||
for (const pr of data.pullRequests.nodes) {
|
||||
if (!pr.reviews?.nodes) continue;
|
||||
// We only count one review per author per PR to avoid counting multiple review comments as multiple reviews
|
||||
const reviewersOnPR = new Set<string>();
|
||||
|
||||
for (const review of pr.reviews.nodes) {
|
||||
if (
|
||||
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(
|
||||
review.authorAssociation,
|
||||
) &&
|
||||
review.author?.login
|
||||
) {
|
||||
const login = review.author.login.toLowerCase();
|
||||
if (login.endsWith('[bot]') || login.includes('bot')) {
|
||||
continue; // Ignore bots
|
||||
}
|
||||
reviewersOnPR.add(review.author.login);
|
||||
}
|
||||
}
|
||||
|
||||
for (const reviewer of reviewersOnPR) {
|
||||
reviewCounts[reviewer] = (reviewCounts[reviewer] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const counts = Object.values(reviewCounts);
|
||||
|
||||
let variance = 0;
|
||||
if (counts.length > 0) {
|
||||
const mean = counts.reduce((a, b) => a + b, 0) / counts.length;
|
||||
variance =
|
||||
counts.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / counts.length;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`review_distribution_variance,${Math.round(variance * 100) / 100}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @license
|
||||
*/
|
||||
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(last: 100, states: MERGED) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
mergedAt
|
||||
}
|
||||
}
|
||||
issues(last: 100, states: CLOSED) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
closedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(
|
||||
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
const data = JSON.parse(output).data.repository;
|
||||
|
||||
const prs = data.pullRequests.nodes
|
||||
.map((p: { authorAssociation: string; mergedAt: string }) => ({
|
||||
association: p.authorAssociation,
|
||||
date: new Date(p.mergedAt).getTime(),
|
||||
}))
|
||||
.sort((a: { date: number }, b: { date: number }) => a.date - b.date);
|
||||
|
||||
const issues = data.issues.nodes
|
||||
.map((i: { authorAssociation: string; closedAt: string }) => ({
|
||||
association: i.authorAssociation,
|
||||
date: new Date(i.closedAt).getTime(),
|
||||
}))
|
||||
.sort((a: { date: number }, b: { date: number }) => a.date - b.date);
|
||||
|
||||
const isMaintainer = (assoc: string) =>
|
||||
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
|
||||
|
||||
const calculateThroughput = (
|
||||
items: { association: string; date: number }[],
|
||||
) => {
|
||||
if (items.length < 2) return 0;
|
||||
const first = items[0].date;
|
||||
const last = items[items.length - 1].date;
|
||||
const days = (last - first) / (1000 * 60 * 60 * 24);
|
||||
return days > 0 ? items.length / days : items.length; // items per day
|
||||
};
|
||||
|
||||
const prOverall = calculateThroughput(prs);
|
||||
const prMaintainers = calculateThroughput(
|
||||
prs.filter((i: { association: string; date: number }) =>
|
||||
isMaintainer(i.association),
|
||||
),
|
||||
);
|
||||
const prCommunity = calculateThroughput(
|
||||
prs.filter(
|
||||
(i: { association: string; date: number }) =>
|
||||
!isMaintainer(i.association),
|
||||
),
|
||||
);
|
||||
|
||||
const issueOverall = calculateThroughput(issues);
|
||||
const issueMaintainers = calculateThroughput(
|
||||
issues.filter((i: { association: string; date: number }) =>
|
||||
isMaintainer(i.association),
|
||||
),
|
||||
);
|
||||
const issueCommunity = calculateThroughput(
|
||||
issues.filter(
|
||||
(i: { association: string; date: number }) =>
|
||||
!isMaintainer(i.association),
|
||||
),
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
`throughput_pr_overall_per_day,${Math.round(prOverall * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`throughput_pr_maintainers_per_day,${Math.round(prMaintainers * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`throughput_pr_community_per_day,${Math.round(prCommunity * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`throughput_issue_overall_per_day,${Math.round(issueOverall * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`throughput_issue_maintainers_per_day,${Math.round(issueMaintainers * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`throughput_issue_community_per_day,${Math.round(issueCommunity * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`throughput_issue_overall_days_per_issue,${issueOverall > 0 ? Math.round((1 / issueOverall) * 100) / 100 : 0}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`throughput_issue_maintainers_days_per_issue,${issueMaintainers > 0 ? Math.round((1 / issueMaintainers) * 100) / 100 : 0}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`throughput_issue_community_days_per_issue,${issueCommunity > 0 ? Math.round((1 / issueCommunity) * 100) / 100 : 0}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @license
|
||||
*/
|
||||
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(last: 100) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
author { login }
|
||||
createdAt
|
||||
comments(first: 20) {
|
||||
nodes {
|
||||
author { login }
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
reviews(first: 20) {
|
||||
nodes {
|
||||
author { login }
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
issues(last: 100) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
author { login }
|
||||
createdAt
|
||||
comments(first: 20) {
|
||||
nodes {
|
||||
author { login }
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(
|
||||
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
const data = JSON.parse(output).data.repository;
|
||||
|
||||
const getFirstResponseTime = (item: {
|
||||
createdAt: string;
|
||||
author: { login: string };
|
||||
comments: { nodes: { createdAt: string; author?: { login: string } }[] };
|
||||
reviews?: { nodes: { createdAt: string; author?: { login: string } }[] };
|
||||
}) => {
|
||||
const authorLogin = item.author?.login;
|
||||
let earliestResponse: number | null = null;
|
||||
|
||||
const checkNodes = (
|
||||
nodes: { createdAt: string; author?: { login: string } }[],
|
||||
) => {
|
||||
for (const node of nodes) {
|
||||
if (node.author?.login && node.author.login !== authorLogin) {
|
||||
const login = node.author.login.toLowerCase();
|
||||
if (login.endsWith('[bot]') || login.includes('bot')) {
|
||||
continue; // Ignore bots
|
||||
}
|
||||
const time = new Date(node.createdAt).getTime();
|
||||
if (!earliestResponse || time < earliestResponse) {
|
||||
earliestResponse = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (item.comments?.nodes) checkNodes(item.comments.nodes);
|
||||
if (item.reviews?.nodes) checkNodes(item.reviews.nodes);
|
||||
|
||||
if (earliestResponse) {
|
||||
return (
|
||||
(earliestResponse - new Date(item.createdAt).getTime()) /
|
||||
(1000 * 60 * 60)
|
||||
);
|
||||
}
|
||||
return null; // No response yet
|
||||
};
|
||||
const processItems = (
|
||||
items: {
|
||||
authorAssociation: string;
|
||||
createdAt: string;
|
||||
author: { login: string };
|
||||
comments: {
|
||||
nodes: { createdAt: string; author?: { login: string } }[];
|
||||
};
|
||||
reviews?: {
|
||||
nodes: { createdAt: string; author?: { login: string } }[];
|
||||
};
|
||||
}[],
|
||||
) => {
|
||||
return items
|
||||
.map((item) => ({
|
||||
association: item.authorAssociation,
|
||||
ttfr: getFirstResponseTime(item),
|
||||
}))
|
||||
.filter((i) => i.ttfr !== null) as {
|
||||
association: string;
|
||||
ttfr: number;
|
||||
}[];
|
||||
};
|
||||
const prs = processItems(data.pullRequests.nodes);
|
||||
const issues = processItems(data.issues.nodes);
|
||||
const allItems = [...prs, ...issues];
|
||||
|
||||
const isMaintainer = (assoc: string) =>
|
||||
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
|
||||
|
||||
const calculateAvg = (items: { ttfr: number; association: string }[]) =>
|
||||
items.length ? items.reduce((a, b) => a + b.ttfr, 0) / items.length : 0;
|
||||
|
||||
const maintainers = calculateAvg(
|
||||
allItems.filter((i) => isMaintainer(i.association)),
|
||||
);
|
||||
const overall = calculateAvg(allItems);
|
||||
|
||||
process.stdout.write(
|
||||
`time_to_first_response_overall_hours,${Math.round(overall * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`time_to_first_response_maintainers_hours,${Math.round(maintainers * 100) / 100}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @license
|
||||
*/
|
||||
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from '../types.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(last: 100, states: MERGED) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
comments { totalCount }
|
||||
reviews { totalCount }
|
||||
}
|
||||
}
|
||||
issues(last: 100, states: CLOSED) {
|
||||
nodes {
|
||||
authorAssociation
|
||||
comments { totalCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const output = execSync(
|
||||
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
const data = JSON.parse(output).data.repository;
|
||||
|
||||
const prs = data.pullRequests.nodes;
|
||||
const issues = data.issues.nodes;
|
||||
|
||||
const allItems = [
|
||||
...prs.map(
|
||||
(p: {
|
||||
authorAssociation: string;
|
||||
comments: { totalCount: number };
|
||||
reviews?: { totalCount: number };
|
||||
}) => ({
|
||||
association: p.authorAssociation,
|
||||
touches: p.comments.totalCount + (p.reviews ? p.reviews.totalCount : 0),
|
||||
}),
|
||||
),
|
||||
...issues.map(
|
||||
(i: { authorAssociation: string; comments: { totalCount: number } }) => ({
|
||||
association: i.authorAssociation,
|
||||
touches: i.comments.totalCount,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
const isMaintainer = (assoc: string) =>
|
||||
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
|
||||
|
||||
const calculateAvg = (items: { touches: number; association: string }[]) =>
|
||||
items.length ? items.reduce((a, b) => a + b.touches, 0) / items.length : 0;
|
||||
|
||||
const overall = calculateAvg(allItems);
|
||||
const maintainers = calculateAvg(
|
||||
allItems.filter((i) => isMaintainer(i.association)),
|
||||
);
|
||||
const community = calculateAvg(
|
||||
allItems.filter((i) => !isMaintainer(i.association)),
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
`user_touches_overall,${Math.round(overall * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`user_touches_maintainers,${Math.round(maintainers * 100) / 100}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`user_touches_community,${Math.round(community * 100) / 100}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user