mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-09 01:27:53 +08:00
205 lines
7.6 KiB
JavaScript
205 lines
7.6 KiB
JavaScript
import { repositoryApi } from './factory-lib.mjs';
|
|
import { selectSupplement, adoptSupplement } from './replay-build-review.mjs';
|
|
import { executionFacts } from './evaluation-report.mjs';
|
|
import { makeDeliveryReport } from './delivery-report.mjs';
|
|
import { readStageTimings, renderTaskReport } from './task-report.mjs';
|
|
import { waitForTaskRun } from './wait-for-task-run.mjs';
|
|
import {
|
|
appendFileSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import path from 'node:path';
|
|
import {
|
|
aggregate,
|
|
collectUsage,
|
|
emptyUsage,
|
|
marker,
|
|
positive,
|
|
recordsFromComments,
|
|
renderUsage,
|
|
selectSource,
|
|
validateRecord,
|
|
} from './task-usage.mjs';
|
|
|
|
const [mode, ...argv] = process.argv.slice(2);
|
|
if (argv.length % 2) throw new Error('Expected --name value arguments');
|
|
const args = Object.fromEntries(
|
|
Array.from({ length: argv.length / 2 }, (_, i) => [
|
|
argv[i * 2].replace(/^--/, ''),
|
|
argv[i * 2 + 1],
|
|
]),
|
|
);
|
|
const repository = process.env.GITHUB_REPOSITORY;
|
|
if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? ''))
|
|
throw new Error('Invalid repository');
|
|
const runId = Number(args['run-id']);
|
|
if (!positive(runId)) throw new Error('Invalid run ID');
|
|
const output = (key, value) =>
|
|
process.env.GITHUB_OUTPUT &&
|
|
appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);
|
|
|
|
// Retries transient failures of idempotent requests; see repositoryApi.
|
|
const github = repositoryApi();
|
|
function api(method, route, body) {
|
|
return github(method, route, body);
|
|
}
|
|
async function list(route, key) {
|
|
const items = [];
|
|
for (let page = 1; ; page++) {
|
|
const response = await api(
|
|
'GET',
|
|
`${route}${route.includes('?') ? '&' : '?'}per_page=100&page=${page}`,
|
|
);
|
|
const batch = key ? response[key] : response;
|
|
if (!Array.isArray(batch)) throw new Error('Invalid list response');
|
|
items.push(...batch);
|
|
if (batch.length < 100) return items;
|
|
}
|
|
}
|
|
async function checkIssue(issue) {
|
|
const data = await api('GET', `/issues/${issue}`);
|
|
if (data.pull_request) throw new Error('Task number must refer to an Issue');
|
|
return data;
|
|
}
|
|
|
|
if (mode === 'select') {
|
|
output('ready', 'false');
|
|
const attempt = args.attempt ? Number(args.attempt) : null;
|
|
if (attempt !== null && !positive(attempt))
|
|
throw new Error('Invalid run attempt');
|
|
const repo = await api('GET', '');
|
|
const run = await waitForTaskRun(api, {
|
|
runId,
|
|
attempt,
|
|
repository,
|
|
defaultBranch: repo.default_branch,
|
|
});
|
|
const jobs = await list(
|
|
`/actions/runs/${runId}/attempts/${run.run_attempt}/jobs`,
|
|
'jobs',
|
|
);
|
|
const artifacts = await list(`/actions/runs/${runId}/artifacts`, 'artifacts');
|
|
const source = selectSource(run, jobs, artifacts, repository);
|
|
if (source) {
|
|
await checkIssue(source.issue);
|
|
if (args['review-run-id'] || args['review-artifact-id']) {
|
|
source.reviewSource = await selectSupplement(route => api('GET', route), repository,
|
|
Number(args['review-run-id']), Number(args['review-artifact-id']));
|
|
output('review_artifact', source.reviewSource.artifactId);
|
|
}
|
|
writeFileSync(args.source, JSON.stringify(source));
|
|
output('artifact_id', source.artifactId || '');
|
|
output('artifact', source.artifact || '');
|
|
output('task_artifact_id', source.taskArtifactId || '');
|
|
output('final_artifact_id', source.finalArtifactId || '');
|
|
output('ready', 'true');
|
|
} else console.log('No accepted task artifacts; skipping usage report.');
|
|
} else if (mode === 'publish') {
|
|
const source = JSON.parse(readFileSync(args.source, 'utf8'));
|
|
if (
|
|
source.repository !== repository ||
|
|
source.runId !== runId ||
|
|
!positive(source.issue)
|
|
)
|
|
throw new Error('Source mismatch');
|
|
if (source.reviewSource) await adoptSupplement(args.artifacts, args.review, source);
|
|
const issue = await checkIssue(source.issue);
|
|
let usage = emptyUsage();
|
|
if (source.invoked) {
|
|
if (source.artifact) usage = await collectUsage(args.artifacts);
|
|
else usage.incomplete++;
|
|
}
|
|
const record = validateRecord({ ...source, usage }, repository, source.issue);
|
|
// Trusted run identity lets later exports link handoffs without expired artifacts.
|
|
// Prefer the prepare-job artifact; the Agent artifact copy never supplies a batch-sample key.
|
|
for (const root of [args.task, args.artifacts].filter(Boolean)) {
|
|
try {
|
|
const metadata = JSON.parse(readFileSync(path.join(root, 'task-metadata.json'), 'utf8'));
|
|
if (metadata.repository !== repository || metadata.issue?.number !== record.issue ||
|
|
(metadata.run && Number(metadata.run.id) !== record.runId)) throw new Error('metadata does not match this run');
|
|
const facts = executionFacts(metadata, args.artifacts, record);
|
|
if (root !== args.task && facts?.kind === 'batch-sample') throw new Error('batch-sample identity requires the prepare record');
|
|
record.evaluation = facts;
|
|
break;
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') console.warn(`::warning::Evaluation identity unavailable: ${error.message}`);
|
|
}
|
|
}
|
|
const comments = await list(`/issues/${source.issue}/comments`);
|
|
const existing = comments.find(
|
|
(c) =>
|
|
c.user?.login === 'github-actions[bot]' &&
|
|
c.body?.includes(marker(record)),
|
|
);
|
|
const prior = recordsFromComments(comments, repository, source.issue);
|
|
const old = prior.find(
|
|
(r) => r.runId === record.runId && r.attempt === record.attempt,
|
|
);
|
|
// Replaying after artifact expiry must not discard a previously measured run.
|
|
if (
|
|
old &&
|
|
old.agentJobId === record.agentJobId &&
|
|
old.usage.records > record.usage.records
|
|
)
|
|
record.usage = old.usage;
|
|
const records = [
|
|
...prior.filter(
|
|
(r) => r.runId !== record.runId || r.attempt !== record.attempt,
|
|
),
|
|
record,
|
|
];
|
|
const reportUrl = process.env.GITHUB_RUN_ID
|
|
? `https://github.com/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}#artifacts`
|
|
: '';
|
|
const body =
|
|
renderUsage(record, records) +
|
|
(reportUrl
|
|
? `\n\n[查看本次 HTML / JSON 报告](${reportUrl})(在 Artifacts 下载后打开 report.html)。`
|
|
: '');
|
|
mkdirSync(path.dirname(path.resolve(args.summary)), { recursive: true });
|
|
const report = {
|
|
record,
|
|
cumulative: aggregate(records),
|
|
records,
|
|
timings: readStageTimings(path.join(args.artifacts, 'timings.jsonl')),
|
|
};
|
|
let html;
|
|
try {
|
|
const result = await makeDeliveryReport(report, args.artifacts, issue,
|
|
(branch) => list(`/pulls?state=all&head=${encodeURIComponent(`${repository.split('/')[0]}:${branch}`)}`));
|
|
report.delivery = result.facts;
|
|
report.pr = result.pr;
|
|
report.reportId = result.reportId;
|
|
html = result.html;
|
|
output('html_ready', 'true');
|
|
} catch (error) {
|
|
// Presentation must never erase numeric usage or restart a business build.
|
|
console.warn(`::warning::Full report unavailable: ${error.message}`);
|
|
report.presentationError = '完整报告生成失败,已保留基础统计报告。';
|
|
html = renderTaskReport(report);
|
|
}
|
|
writeFileSync(args.summary, `${JSON.stringify(report, null, 2)}\n`);
|
|
writeFileSync(path.join(path.dirname(args.summary), 'report.html'), html);
|
|
if (process.env.GITHUB_STEP_SUMMARY)
|
|
appendFileSync(
|
|
process.env.GITHUB_STEP_SUMMARY,
|
|
body.split('<!-- factory-task-usage-data')[0],
|
|
);
|
|
if (existing?.body !== body)
|
|
await api(
|
|
existing ? 'PATCH' : 'POST',
|
|
existing
|
|
? `/issues/comments/${existing.id}`
|
|
: `/issues/${record.issue}/comments`,
|
|
{ body },
|
|
);
|
|
console.log(
|
|
`Usage and duration reported to Issue #${record.issue} for run ${runId}, attempt ${record.attempt}.`,
|
|
);
|
|
} else
|
|
throw new Error(
|
|
'Usage: report-task-usage.mjs <select|publish> --run-id N --source FILE ...',
|
|
);
|