From a75994bf6dc396f99a901298539846da35aa91d7 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 16 Sep 2026 18:28:11 -0400 Subject: [PATCH] history: Retain only AIC-linked item details Canonical snapshots should preserve the AIC evidence that is difficult for consumers to reconstruct instead of duplicating general GitHub activity. Keep full activity aggregates while making every retained item-to-run association auditable from serialized evidence. Assisted-by: AI Signed-off-by: Colin Walters --- .github/workflows/ci.yml | 9 ++- .github/workflows/weekly-history.yml | 4 +- scripts/README.md | 8 +- scripts/org-history.js | 112 ++++++++++++++++++++++++--- tests/org-history.test.js | 76 +++++++++++++++++- 5 files changed, 189 insertions(+), 20 deletions(-) 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')); +});