diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a94f28..8f68332 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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']); @@ -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$/; @@ -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 diff --git a/.github/workflows/weekly-history.yml b/.github/workflows/weekly-history.yml index 344d1c4..fbead1c 100644 --- a/.github/workflows/weekly-history.yml +++ b/.github/workflows/weekly-history.yml @@ -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 diff --git a/scripts/README.md b/scripts/README.md index cd4128f..ab55446 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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. diff --git a/scripts/org-history.js b/scripts/org-history.js index 16c3f90..4d7906c 100644 --- a/scripts/org-history.js +++ b/scripts/org-history.js @@ -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'; @@ -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` }; @@ -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(//gi)]; @@ -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'), }; } @@ -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); @@ -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 { @@ -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) { @@ -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; @@ -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); @@ -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 }; diff --git a/tests/org-history.test.js b/tests/org-history.test.js index 9886228..bba6d58 100644 --- a/tests/org-history.test.js +++ b/tests/org-history.test.js @@ -4,7 +4,7 @@ const assert = require('node:assert/strict'); const childProcess = require('node:child_process'); const path = require('node:path'); const test = require('node:test'); -const { historyFilename, parsePeriod, previousCompleteIsoWeek } = require('../scripts/org-history.js'); +const { aicLinkedItems, deduplicateCommentAic, historyFilename, normalizedPullRequests, parseCommentAic, parsePeriod, previousCompleteIsoWeek, validateDetailedItems } = require('../scripts/org-history.js'); const script = path.join(__dirname, '..', 'scripts', 'org-history.js'); @@ -52,3 +52,77 @@ test('supports deterministic as-of week selection from the CLI', () => { assert.equal(childProcess.execFileSync(process.execPath, [script, '--previous-iso-week', '--as-of', '2026-01-01T12:00:00Z'], { encoding: 'utf8' }).trim(), '2025-W52'); assert.throws(() => childProcess.execFileSync(process.execPath, [script, '--previous-iso-week', '--as-of', 'not-a-date'], { encoding: 'utf8' })); }); + +test('links detailed items only through exact known-AIC comment or PR evidence', () => { + const items = [ + { repository: 'repo', number: 1, type: 'issue', url: 'https://github.com/org/repo/issues/1' }, + { repository: 'repo', number: 2, type: 'pull_request', url: 'https://github.com/org/repo/pull/2' }, + { repository: 'repo', number: 3, type: 'issue', url: 'https://github.com/org/repo/issues/3' }, + ]; + const run = (id, aic, pullRequests = []) => ({ id, repository: 'repo', kind: 'drafter', aic, pull_requests: pullRequests }); + const pull = (number, repository = 'repo') => ({ url: `https://api.github.com/repos/org/${repository}/pulls/${number}` }); + for (const scenario of [ + { name: 'comment', runs: [run(10, 1)], comments: [{ repository: 'repo', runId: 10, itemNumber: 1 }], expected: [[1, [10]]] }, + { name: 'pull request', runs: [run(11, 2, [pull(2)])], comments: [], expected: [[2, [11]]] }, + { name: 'unrelated evidence', runs: [run(12, 3, [pull(3, 'other')]), run(13, null, [pull(2)])], comments: [{ repository: 'repo', runId: 12, itemNumber: 99 }], expected: [] }, + { name: 'deduplicates links', runs: [run(14, 4, [pull(2), pull(2)])], comments: [{ repository: 'repo', runId: 14, itemNumber: 2 }, { repository: 'repo', runId: 14, itemNumber: 2 }], expected: [[2, [14]]] }, + { name: 'keeps multiple comment targets', runs: [run(16, 5)], comments: [{ repository: 'repo', runId: 16, itemNumber: 1 }, { repository: 'repo', runId: 16, itemNumber: 3 }], expected: [[1, [16]], [3, [16]]] }, + { name: 'retains zero AIC', runs: [run(15, 0)], comments: [{ repository: 'repo', runId: 15, itemNumber: 1 }], expected: [[1, [15]]] }, + ]) { + const actual = aicLinkedItems(items, scenario.runs, scenario.comments, 'org').map((item) => [item.number, item.aicRunIds]); + assert.deepEqual(actual, scenario.expected, scenario.name); + } +}); + +test('parses comment AIC with transparent same-repository item identity', () => { + const comment = parseCommentAic({ + id: 1, + issue_url: 'https://api.github.com/repos/org/repo/issues/7', html_url: 'https://github.com/org/repo/pull/7#issuecomment-9', + body: '> Generated by [workflow](https://github.com/org/repo/actions/runs/42) · 0 AIC', + }, 'org', 'repo'); + assert.deepEqual({ runId: comment.runId, itemNumber: comment.itemNumber, itemUrl: comment.itemUrl, aic: comment.aic }, { + runId: 42, itemNumber: 7, itemUrl: 'https://github.com/org/repo/pull/7', aic: 0, + }); + assert.equal(parseCommentAic({ id: 1, issue_url: 'https://api.github.com/repos/other/repo/issues/7', html_url: 'https://github.com/other/repo/issues/7#issuecomment-9', body: '> Generated by [workflow](https://github.com/org/repo/actions/runs/42) · 1 AIC' }, 'org', 'repo'), null); + assert.equal(parseCommentAic({ id: 1, issue_url: 'https://api.github.com/repos/org/repo/issues/7', html_url: 'https://github.com/org/repo/issues/7#issuecomment-9', body: '> Generated by [workflow](https://github.com/other/repo/actions/runs/42) · 1 AIC' }, 'org', 'repo'), null); + assert.equal(parseCommentAic({ id: 1, issue_url: 'https://api.github.com/repos/org/repo/issues/7', html_url: 'https://github.com/org/repo/issues/7#issuecomment-9', body: '> Generated by [workflow](https://github.com/org/repo/actions/runs/42) · 1 AIC\n' }, 'org', 'repo'), null); +}); + +test('retains consistent comment evidence for every exact item target', () => { + const records = deduplicateCommentAic([ + { runId: 20, commentId: 1, commentUrl: 'one', agentAic: 1, detectionAic: null, aic: 1, repository: 'repo', itemNumber: 1 }, + { runId: 20, commentId: 2, commentUrl: 'two', agentAic: 1, detectionAic: null, aic: 1, repository: 'repo', itemNumber: 2 }, + ]).records; + const items = [{ repository: 'repo', number: 1, type: 'issue' }, { repository: 'repo', number: 2, type: 'issue' }]; + assert.deepEqual(aicLinkedItems(items, [{ id: 20, repository: 'repo', kind: 'drafter', aic: 1 }], records, 'org').map((item) => [item.number, item.aicRunIds]), [[1, [20]], [2, [20]]]); +}); + +test('normalizes only same-repository workflow PR references', () => { + assert.deepEqual(normalizedPullRequests({ pull_requests: [ + { url: 'https://api.github.com/repos/org/repo/pulls/2' }, + { url: 'https://api.github.com/repos/org/repo/pulls/1' }, + { url: 'https://api.github.com/repos/org/repo/pulls/2' }, + { url: 'https://api.github.com/repos/org/other/pulls/3' }, + ] }, 'org', 'repo'), [ + { number: 1, url: 'https://github.com/org/repo/pull/1' }, + { number: 2, url: 'https://github.com/org/repo/pull/2' }, + ]); +}); + +test('validates detailed item links have exact serialized evidence', () => { + const issue = { repository: 'repo', number: 1, type: 'issue', url: 'https://github.com/org/repo/issues/1', aicRunIds: [1] }; + const pullRequest = { repository: 'repo', number: 2, type: 'pull_request', url: 'https://github.com/org/repo/pull/2', aicRunIds: [2] }; + const commentAic = [{ repository: 'repo', runId: 1, itemNumber: 1, itemUrl: issue.url }]; + const workflowRuns = [ + { id: 1, repository: 'repo', aic: 0, pullRequests: [] }, + { id: 2, repository: 'repo', aic: 1, pullRequests: [{ number: 2, url: pullRequest.url }] }, + ]; + assert.doesNotThrow(() => validateDetailedItems([issue, pullRequest], workflowRuns, commentAic, 'org')); + assert.throws(() => validateDetailedItems([issue], [{ id: 1, repository: 'repo', aic: 0, pullRequests: [] }], [], 'org')); + assert.throws(() => validateDetailedItems([pullRequest], [{ id: 2, repository: 'repo', aic: 1, pullRequests: [{ number: 2, url: issue.url }] }], [], 'org')); + assert.throws(() => validateDetailedItems([{ ...issue, type: 'pull_request' }], workflowRuns, commentAic, 'org')); + assert.throws(() => validateDetailedItems([{ ...issue, url: 'https://github.com/other/repo/issues/1' }], workflowRuns, [{ ...commentAic[0], itemUrl: 'https://github.com/other/repo/issues/1' }], 'org')); + assert.throws(() => validateDetailedItems([{ ...issue, aicRunIds: [1, 1] }], workflowRuns, commentAic, 'org')); + assert.throws(() => validateDetailedItems([{ ...issue, aicRunIds: [2] }], [{ id: 2, repository: 'repo', aic: null, pullRequests: [] }], commentAic, 'org')); + assert.throws(() => validateDetailedItems([{ ...issue, aicRunIds: [3] }], [{ id: 3, repository: 'other', aic: 1, pullRequests: [] }], commentAic, 'org')); +});