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
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,13 @@ jobs:
node - "$RUNNER_TEMP/org-history.json" "$SNAPSHOT_PERIOD" <<'NODE'
const assert = require('node:assert/strict');
const snapshot = require(process.argv[2]);
assert.equal(snapshot.schemaVersion, 4);
assert.equal(snapshot.schemaVersion, 5);
assert.equal(snapshot.org, 'bootc-dev');
assert.equal(snapshot.period, process.argv[3]);
assert.equal(snapshot.repositoryFilter, 'gh-agentic-workflows');
for (const key of ['repositories', 'items', 'workflowRuns', 'commentAic', 'errors']) assert.ok(Array.isArray(snapshot[key]), `${key} is an array`);
const { validateDetailedItems } = require('./scripts/org-history.js');
validateDetailedItems(snapshot.items, snapshot.workflowRuns, snapshot.commentAic, snapshot.org);
assert.equal(snapshot.repositories.length, 1);
assert.equal(snapshot.repositories[0].name, 'gh-agentic-workflows');
assert.deepEqual(Object.keys(snapshot.coverage).sort(), ['aic', 'repositories']);
Expand Down Expand Up @@ -106,7 +108,7 @@ jobs:
const assert = require('node:assert/strict');
const childProcess = require('node:child_process');
const fs = require('node:fs');
const { historyFilename, parsePeriod } = require('./scripts/org-history.js');
const { historyFilename, parsePeriod, validateDetailedItems } = require('./scripts/org-history.js');
const base = process.argv[2];
const fields = childProcess.execFileSync('git', ['diff', '--name-status', '-z', base, 'HEAD'], { encoding: 'utf8' }).split('\0');
const weekly = /^history\/\d{4}-\d{2}\.json$/;
Expand All @@ -120,9 +122,10 @@ jobs:
if (!added || !weekly.test(added)) continue;
const snapshot = JSON.parse(fs.readFileSync(added, 'utf8'));
const period = added.slice('history/'.length, -'.json'.length).replace('-', '-W');
assert.equal(snapshot.schemaVersion, 4, `${added} schema version`);
assert.equal(snapshot.schemaVersion, 5, `${added} schema version`);
assert.equal(snapshot.period, period, `${added} period`);
assert.equal(historyFilename(snapshot.period), added.slice('history/'.length), `${added} filename`);
parsePeriod(snapshot.period);
validateDetailedItems(snapshot.items, snapshot.workflowRuns, snapshot.commentAic, snapshot.org);
}
NODE
4 changes: 3 additions & 1 deletion .github/workflows/weekly-history.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,13 @@ jobs:
const assert = require('node:assert/strict');
const fs = require('node:fs');
const snapshot = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
assert.equal(snapshot.schemaVersion, 4);
assert.equal(snapshot.schemaVersion, 5);
assert.equal(snapshot.org, 'bootc-dev');
assert.equal(snapshot.period, process.argv[3]);
assert.match(snapshot.period, /^\d{4}-W\d{2}$/);
for (const key of ['repositories', 'items', 'workflowRuns', 'commentAic', 'errors']) assert.ok(Array.isArray(snapshot[key]));
const { validateDetailedItems } = require('./scripts/org-history.js');
validateDetailedItems(snapshot.items, snapshot.workflowRuns, snapshot.commentAic, snapshot.org);
NODE

- name: Open or update history pull request
Expand Down
8 changes: 5 additions & 3 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ node scripts/org-history.js ORG 2026-09 --output /tmp/ORG-2026-09-history.json -
node scripts/org-history.js --previous-iso-week
```

Schema v4 replaces `month` with `period`, `createdInMonth`/`mergedInMonth` with
`createdInPeriod`/`mergedInPeriod`, and the attribution cohort
`items_created_in_month` with `items_created_in_period`.
Schema v5 retains the v4 period field names and changes detailed `items`: it contains
only items with exact evidence linking them to a relevant workflow run with known AIC.
Each detailed item has sorted, unique `aicRunIds`. Repository aggregates still describe
the full collected activity cohort, not just these AIC-linked detailed items; the linked
run evidence is serialized for audit.

Pass `--repo REPO` to collect one non-archived, non-fork repository within `ORG` instead
of the full visible organization. The resulting snapshot records the repository filter.
Expand Down
112 changes: 100 additions & 12 deletions scripts/org-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');

const SCHEMA_VERSION = 4;
const SCHEMA_VERSION = 5;
const AGENT_LABELS = ['agent/code', 'agent/fixme', 'agent/lgtm'];
const AGGREGATE_USAGE_FILE = 'agent_usage.json';
const TOKEN_USAGE_FILE = 'agent/token_usage.jsonl';
Expand Down Expand Up @@ -142,6 +142,47 @@ function aggregateRepository(items, workflows) {

function finiteNonnegative(value) { return typeof value === 'number' && Number.isFinite(value) && value >= 0; }

function itemIdentityFromUrl(url, org, repository) {
if (typeof url !== 'string') return null;
const escapedOrg = org.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(`^https://(?:api\\.)?github\\.com/${url.includes('api.github.com') ? 'repos/' : ''}${escapedOrg}/${escapedRepository}/(?:issues|pulls)/(\\d+)$`).exec(url);
if (!match) return null;
const number = Number(match[1]);
return Number.isSafeInteger(number) && number > 0 ? { number } : null;
}

function commentItemIdentity(issueUrl, commentUrl, org, repository) {
const issue = itemIdentityFromUrl(issueUrl, org, repository);
if (!issue || typeof commentUrl !== 'string') return null;
const escapedOrg = org.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(`^(https://github\\.com/${escapedOrg}/${escapedRepository}/(issues|pull)/(\\d+))(?:#issuecomment-\\d+)?$`).exec(commentUrl);
if (!match || Number(match[3]) !== issue.number) return null;
return { number: issue.number, url: match[1] };
}

function runUrlForRepository(url, org, repository) {
if (typeof url !== 'string') return null;
const escapedOrg = org.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(`^https://github\\.com/${escapedOrg}/${escapedRepository}/actions/runs/(\\d+)$`).exec(url);
return match && Number.isSafeInteger(Number(match[1])) && Number(match[1]) > 0 ? Number(match[1]) : null;
}

function normalizedPullRequests(run, org, repository) {
const escapedOrg = org.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`^https://api\\.github\\.com/repos/${escapedOrg}/${escapedRepository}/pulls/(\\d+)$`);
const references = new Map();
for (const pullRequest of run.pull_requests || []) {
const match = pattern.exec(pullRequest.url);
const number = match && Number(match[1]);
if (Number.isSafeInteger(number) && number > 0) references.set(number, { number, url: `https://github.com/${org}/${repository}/pull/${number}` });
}
return [...references.values()].sort((a, b) => a.number - b.number);
}

function cumulativeUsage(files, filename, component) {
const selected = files.filter((file) => file.name === filename);
if (!selected.length) return { known: false, aic: null, records: 0, source: `${component}_missing` };
Expand Down Expand Up @@ -191,7 +232,7 @@ function normalizeAic(value) { return Number(value.toFixed(6)); }
// Only read the machine-produced footer, not arbitrary discussion of AIC in a
// comment. The optional hidden marker is emitted by newer gh-aw versions and
// gives an independently labelled run identity.
function parseCommentAic(comment) {
function parseCommentAic(comment, org, repository) {
const body = typeof comment.body === 'string' ? comment.body : '';
const footers = [...body.matchAll(/^>\s*Generated by\s+\[[^\]]+\]\((https:\/\/github\.com\/[^\s)]+\/actions\/runs\/(\d+))\)([^\r\n]*)$/gim)];
const hiddenMarkers = [...body.matchAll(/<!--\s*gh-aw-agentic-workflow:\s*[^>]*?\bid:\s*(\d+)\s*,\s*workflow_id:\s*[^,\s]+\s*,\s*run:\s*(https:\/\/github\.com\/[^\s>]+\/actions\/runs\/(\d+))\s*-->/gi)];
Expand All @@ -210,12 +251,14 @@ function parseCommentAic(comment) {
const expandAic = (token) => Number(token[2]) * (token[3] === 'K' ? 1_000 : token[3] === 'M' ? 1_000_000 : 1);
const agentAic = expandAic(agent[0]);
const detectionAic = detection.length ? expandAic(detection[0]) : null;
if (!Number.isSafeInteger(Number(footerRunId)) || Number(footerRunId) <= 0 || !Number.isSafeInteger(comment.id) || comment.id <= 0 || !finiteNonnegative(agentAic) || (detectionAic !== null && !finiteNonnegative(detectionAic))) return null;
const item = commentItemIdentity(comment.issue_url, comment.html_url, org, repository);
if (!Number.isSafeInteger(Number(footerRunId)) || Number(footerRunId) <= 0 || runUrlForRepository(footer[1], org, repository) !== Number(footerRunId) || (hidden && runUrlForRepository(hidden[2], org, repository) !== Number(hiddenRunId)) || !Number.isSafeInteger(comment.id) || comment.id <= 0 || !item || !finiteNonnegative(agentAic) || (detectionAic !== null && !finiteNonnegative(detectionAic))) return null;
return {
runId: Number(footerRunId), agentAic: normalizeAic(agentAic), detectionAic: detectionAic === null ? null : normalizeAic(detectionAic),
aic: normalizeAic(agentAic + (detectionAic === null ? 0 : detectionAic)),
actor: comment.user && comment.user.login || null, createdAt: comment.created_at || null, updatedAt: comment.updated_at || null,
footerRunUrl: footer[1], commentUrl: comment.html_url || comment.url || null, commentId: comment.id,
itemNumber: item.number, itemUrl: item.url,
bodySha256: crypto.createHash('sha256').update(body).digest('hex'),
};
}
Expand All @@ -237,13 +280,14 @@ function deduplicateCommentAic(records) {
const first = ordered[0];
if (ordered.some((record) => record.agentAic !== first.agentAic || record.detectionAic !== first.detectionAic || record.aic !== first.aic)) {
errors.push({ runId, message: 'conflicting comment AIC records' });
} else accepted.push(first);
} else accepted.push(...ordered);
}
return { records: accepted, errors };
}

function applyCommentAicFallback(runs, records) {
const byRun = new Map(records.map((record) => [record.runId, record]));
const byRun = new Map();
for (const record of records) if (!byRun.has(record.runId)) byRun.set(record.runId, record);
let commentBackedRuns = 0;
for (const run of runs) {
const record = byRun.get(run.id);
Expand All @@ -258,9 +302,9 @@ function applyCommentAicFallback(runs, records) {
return commentBackedRuns;
}

function integrateCommentAic(comments, bot, interval, relevantRuns) {
function integrateCommentAic(comments, bot, interval, relevantRuns, org, repository) {
const eligibleById = new Map(relevantRuns.filter(aicEligible).map((run) => [run.id, run]));
const parsed = botCommentsInInterval(comments, bot, interval).map(parseCommentAic)
const parsed = botCommentsInInterval(comments, bot, interval).map((comment) => parseCommentAic(comment, org, repository))
.filter((record) => record && eligibleById.has(record.runId));
const usage = deduplicateCommentAic(parsed);
return {
Expand All @@ -270,6 +314,50 @@ function integrateCommentAic(comments, bot, interval, relevantRuns) {
};
}

function aicLinkedItems(items, runs, commentAic, org) {
const itemsByKey = new Map(items.map((item) => [`${item.repository}/${item.number}`, item]));
const runById = new Map(runs.filter((run) => (run.kind || classifyWorkflow(run)) && finiteNonnegative(run.aic)).map((run) => [run.id, run]));
const links = new Map();
const link = (repository, number, runId) => {
const item = itemsByKey.get(`${repository}/${number}`);
const run = runById.get(runId);
if (!item || !run || run.repository !== repository) return;
const ids = links.get(item) || new Set();
ids.add(runId);
links.set(item, ids);
};
for (const evidence of commentAic) link(evidence.repository, evidence.itemNumber, evidence.runId);
for (const run of runById.values()) {
for (const pullRequest of normalizedPullRequests(run, org, run.repository)) {
const candidate = itemsByKey.get(`${run.repository}/${pullRequest.number}`);
if (candidate && candidate.type === 'pull_request' && candidate.url === pullRequest.url) {
link(run.repository, pullRequest.number, run.id);
}
}
}
return [...links.entries()].map(([item, runIds]) => ({ ...item, aicRunIds: [...runIds].sort((a, b) => a - b) }))
.sort((a, b) => a.repository.localeCompare(b.repository) || a.number - b.number);
}

function validateDetailedItems(items, workflowRuns, commentAic, org) {
const runById = new Map(workflowRuns.map((run) => [run.id, run]));
for (const item of items) {
if (!Array.isArray(item.aicRunIds) || !item.aicRunIds.length) throw new Error(`Detailed item ${item.repository}#${item.number} has no AIC runs`);
const itemPath = item.type === 'pull_request' ? 'pull' : item.type === 'issue' ? 'issues' : null;
const canonicalItemUrl = itemPath && `https://github.com/${org}/${item.repository}/${itemPath}/${item.number}`;
if (item.url !== canonicalItemUrl) throw new Error(`Detailed item ${item.repository}#${item.number} has a mismatched canonical URL`);
const sortedUnique = [...new Set(item.aicRunIds)].sort((a, b) => a - b);
if (JSON.stringify(item.aicRunIds) !== JSON.stringify(sortedUnique)) throw new Error(`Detailed item ${item.repository}#${item.number} has unsorted or duplicate AIC runs`);
for (const runId of item.aicRunIds) {
const run = runById.get(runId);
if (!run || run.repository !== item.repository || !finiteNonnegative(run.aic)) throw new Error(`Detailed item ${item.repository}#${item.number} references unknown-AIC run ${runId}`);
const commentEvidence = (commentAic || []).some((record) => record.repository === item.repository && record.runId === runId && record.itemNumber === item.number && record.itemUrl === item.url);
const pullRequestEvidence = item.type === 'pull_request' && (run.pullRequests || []).some((reference) => reference.number === item.number && reference.url === item.url);
if (!commentEvidence && !pullRequestEvidence) throw new Error(`Detailed item ${item.repository}#${item.number} lacks exact evidence for run ${runId}`);
}
}
}

function summarizeAicCoverage(runs) {
const coverage = { relevantRuns: runs.length, eligibleRuns: 0, ineligibleRuns: 0, runsWithValues: 0, artifactBackedRuns: 0, commentBackedRuns: 0, missingOrExpired: 0, total: null };
for (const run of runs) {
Expand Down Expand Up @@ -400,7 +488,7 @@ function collect(org, interval, bot, repositoryFilter) {
const pull = prByNumber.get(item.number);
return pull ? { ...item, ...pull, pull_request: item.pull_request } : item;
}), name, interval).filter((item) => inInterval(item.updatedAt, interval) || item.createdInPeriod || item.mergedInPeriod);
const runs = fetchWorkflowRuns(org, name, interval).filter((run) => inInterval(run.created_at, interval));
const runs = fetchWorkflowRuns(org, name, interval).filter((run) => inInterval(run.created_at, interval)).map((run) => ({ ...run, repository: name }));
const relevant = runs.filter(classifyWorkflow);
for (const run of relevant) {
if (!aicEligible(run)) continue;
Expand All @@ -409,14 +497,14 @@ function collect(org, interval, bot, repositoryFilter) {
}
if (bot) {
try {
const commentUsage = integrateCommentAic(paged(`repos/${org}/${name}/issues/comments?since=${encodeURIComponent(issueSince(interval))}&per_page=100`), bot, interval, relevant);
const commentUsage = integrateCommentAic(paged(`repos/${org}/${name}/issues/comments?since=${encodeURIComponent(issueSince(interval))}&per_page=100`), bot, interval, relevant, org, name);
for (const error of commentUsage.errors) repositoryErrors.push({ repository: name, operation: `comment/${error.runId}`, message: error.message });
snapshot.commentAic.push(...commentUsage.evidence.map((record) => ({ repository: name, ...record })));
} catch (error) { repositoryErrors.push({ repository: name, operation: 'comments', message: error.message }); }
}
repositoryAic = summarizeAicCoverage(relevant);
const workflowRuns = runs.map((run) => ({ repository: name, id: run.id, url: run.html_url, name: run.name, path: run.path, createdAt: run.created_at, conclusion: run.conclusion, kind: classifyWorkflow(run), aic: run.aic === undefined ? null : run.aic, aicSource: run.aicSource || null, aicRecords: run.aicRecords === undefined ? null : run.aicRecords, aicAgent: run.aicAgent === undefined ? null : run.aicAgent, aicDetection: run.aicDetection === undefined ? null : run.aicDetection, aicCommentActor: run.aicCommentActor || null, aicCommentCreatedAt: run.aicCommentCreatedAt || null, aicCommentUpdatedAt: run.aicCommentUpdatedAt || null, aicFooterRunUrl: run.aicFooterRunUrl || null, aicCommentUrl: run.aicCommentUrl || null, aicCommentId: run.aicCommentId === undefined ? null : run.aicCommentId, aicCommentBodySha256: run.aicCommentBodySha256 || null })).filter((run) => run.kind);
snapshot.items.push(...items);
const workflowRuns = runs.map((run) => ({ repository: name, id: run.id, url: run.html_url, name: run.name, path: run.path, createdAt: run.created_at, conclusion: run.conclusion, kind: classifyWorkflow(run), pullRequests: normalizedPullRequests(run, org, name), aic: run.aic === undefined ? null : run.aic, aicSource: run.aicSource || null, aicRecords: run.aicRecords === undefined ? null : run.aicRecords, aicAgent: run.aicAgent === undefined ? null : run.aicAgent, aicDetection: run.aicDetection === undefined ? null : run.aicDetection, aicCommentActor: run.aicCommentActor || null, aicCommentCreatedAt: run.aicCommentCreatedAt || null, aicCommentUpdatedAt: run.aicCommentUpdatedAt || null, aicFooterRunUrl: run.aicFooterRunUrl || null, aicCommentUrl: run.aicCommentUrl || null, aicCommentId: run.aicCommentId === undefined ? null : run.aicCommentId, aicCommentBodySha256: run.aicCommentBodySha256 || null })).filter((run) => run.kind);
snapshot.items.push(...aicLinkedItems(items, runs, snapshot.commentAic.filter((record) => record.repository === name), org));
snapshot.workflowRuns.push(...workflowRuns);
snapshot.repositories.push({ name, url: repo.html_url, ...aggregateRepository(items, runs) });
snapshot.errors.push(...repositoryErrors);
Expand Down Expand Up @@ -469,4 +557,4 @@ function main(argv) {
}
if (require.main === module) { try { main(process.argv.slice(2)); } catch (error) { console.error(`org-history: ${error.message}`); process.exitCode = 1; } }

module.exports = { parsePeriod, previousCompleteIsoWeek, historyFilename };
module.exports = { aicLinkedItems, deduplicateCommentAic, historyFilename, normalizedPullRequests, parseCommentAic, parsePeriod, previousCompleteIsoWeek, validateDetailedItems };
Loading