blob: 3db8b02313fd9b7e83a6349b04f5c953f26af06f [file]
// Full read-only compensation scan and GitHub Actions summary. This runs even
// when the model and PR creation are disabled.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {execFileSync} = require('node:child_process');
const {hash, languageName, pipelineLocale} = require('./state.cjs');
const root = path.resolve(__dirname, '../..');
const configInputs = ['docusaurus.config.js', 'config/i18n.js', 'versions.json', 'src/constant/version.ts', 'config/versions-plugin.js', 'sidebars.ts', 'sidebarsCommunity.json',
'sidebarsCourse.ts', 'sidebarsReleases.json',
'versioned_sidebars/version-4.x-sidebars.json', 'translations/config.json', 'translations/release-notes-baseline.json', 'translations/blog-baseline.json'];
// A push to this locale's own scope or glossary changes what may be generated.
const rulesFor = locale => new Set([...configInputs,
`translations/scope/${locale}.json`, `translations/glossary/${locale}.json`]);
function relevantPush(changed, audit, scope) {
const rules = rulesFor(scope.locale);
const source = new Set(audit.groups.flatMap(group => group.entries.map(page => page.sourcePath))
.concat(audit.removed.map(page => page.sourcePath)));
const chinese = new Set(audit.groups.flatMap(group => {
const scopeGroup = scope.groups.find(item => item.id === group.id);
const plugin = group.pluginId === 'default' ? '' : `-${group.pluginId}`;
const version = group.version === 'current' ? 'current' : `version-${group.version}`;
return group.entries.map(page => (scopeGroup.kind==='blog'?'i18n/zh-CN/docusaurus-plugin-content-blog/':`i18n/zh-CN/docusaurus-plugin-content-docs${plugin}/${version}/`)
+ page.sourcePath.slice(scopeGroup.sourceRoot.length + 1));
}));
return changed.some(file => source.has(file) || chinese.has(file) || rules.has(file) || file.startsWith('versioned_sidebars/'));
}
function changedPaths(before, head) {
if (!/^[a-f0-9]{40}$/.test(before ?? '') || /^0{40}$/.test(before)) return null;
try {return execFileSync('git', ['diff', '--name-only','-z', before, head],
{cwd: root, encoding: 'utf8'}).split('\0').filter(Boolean);}
catch {return null;}
}
// Everything a plan depends on besides the planner itself: the checkout, the
// open-PR reservations (blocked targets included) and the requested filters.
function planInputs(head, activeCandidates, env = process.env) {
return hash(JSON.stringify({head, activeCandidates, filters: ['I18N_PLUGIN', 'I18N_VERSION', 'I18N_PATH', 'I18N_PATHS']
.map(key => env[key] || '').concat(env.I18N_LIMIT || '20')}));
}
function summarize(plan, reconciliation, eligible, mode, language, reused = false) {
const effective=plan.automaticCounts??plan.counts;
const exceptionKinds = new Map();
for (const item of [...plan.exceptions, ...reconciliation.exceptions])
exceptionKinds.set(item.kind, (exceptionKinds.get(item.kind) ?? 0) + 1);
return [
`## ${language} translation discovery`,
'',
`- Source commit: \`${plan.sourceCommit}\``,
`- Plan: ${reused ? 'reused from discovery; master and open-PR reservations are unchanged'
: 'recounted from the native documents'}`,
`- Mode: \`${mode}\`; generation eligible: **${eligible}**`,
`- Required for enabled automatic groups: ${effective.required}; merged current: ${effective.mergedCurrent}; pending: ${effective.pending}`,
`- Additional registered content outside automatic maintenance: ${plan.counts.required-effective.required}; not a required translation backlog`,
`- Open PRs inspected: ${reconciliation.openPrCount}; relevant: ${reconciliation.relevantPrs.length}; active candidates: ${plan.counts.activeCandidates}`,
`- Selected for a possible next batch: ${plan.selected.length}; blocked targets: ${reconciliation.blockedTargets.length}`,
`- Outside enabled automatic groups: ${plan.deferredAutomationTasks ?? 0}; reported separately, not sent to a model`,
...['releases','blog'].map(plugin=>`- ${plugin==='blog'?'Blog':'Release-note'} policy: historical excluded=${plan.exceptions.filter(item=>item.pluginId===plugin&&item.kind==='historical_excluded').length}; manual reconciliation=${plan.exceptions.filter(item=>item.pluginId===plugin&&item.kind==='policy_review').length}`),
`- Robot batch PRs already open: ${reconciliation.botPrs.join(', ') || 'none'}`,
`- Exceptions: ${[...exceptionKinds].map(([kind, count]) => `${kind}=${count}`).join(', ') || 'none'}`,
'',
'This report is read only. Open PR candidates do not count as merged translations.',
'',
].join('\n');
}
function main() {
const output = process.argv[2];
assert(output && process.argv.length === 3, 'Usage: workflow-scan.cjs <output-directory>');
assert(process.env.I18N_MODE !== 'validate' || !process.env.I18N_PATHS,
'Validation mode requires path, not a list of paths');
fs.mkdirSync(output, {recursive: true});
const reconciliation = JSON.parse(fs.readFileSync(path.join(output, 'open-pr-reconciliation.json'), 'utf8'));
const planFile = path.join(output, 'plan.json');
const auditFile = path.join(output, 'coverage.json');
const activeFile = path.join(output, 'active-candidates.json');
const event = process.env.GITHUB_EVENT_NAME || 'workflow_dispatch';
const inputs = planInputs(execFileSync('git', ['rev-parse', 'HEAD'], {cwd: root, encoding: 'utf8'}).trim(),
fs.readFileSync(activeFile, 'utf8'));
// The generation job downloads the discovery plan. Recounting every native
// document takes minutes, so an unchanged plan is reused.
const previous = fs.existsSync(planFile) && fs.existsSync(auditFile)
? JSON.parse(fs.readFileSync(planFile, 'utf8')) : null;
const reused = event !== 'push' && previous?.planInputs === inputs;
let plan = previous;
if (!reused) {
const args = [path.join(__dirname, 'plan-translations.cjs'), '--source-ref', 'HEAD',
'--active-candidates', activeFile, '--output', planFile,
'--audit-output', auditFile, '--limit', process.env.I18N_LIMIT || '20'];
for (const [key, option] of [['I18N_PLUGIN', '--plugin'], ['I18N_VERSION', '--version'],
['I18N_PATH', '--path'], ['I18N_PATHS', '--paths']]) {
if (process.env[key]) args.push(option, process.env[key]);
}
execFileSync(process.execPath, args, {cwd: root, stdio: 'inherit'});
plan = JSON.parse(fs.readFileSync(planFile, 'utf8'));
const blocked = new Set(reconciliation.blockedTargets);
const originalSelected = plan.selected.length;
plan.selected = plan.selected.filter(id => !blocked.has(plan.tasks.find(task => task.id === id)?.targetPath));
plan.selectedCount = plan.selected.length;
plan.selectedEstimatedSourceTokens = plan.selected.reduce((total, id) =>
total + (plan.tasks.find(task => task.id === id)?.estimatedSourceTokens || 0), 0);
plan.blockedSelected = originalSelected - plan.selected.length;
plan.planInputs = inputs;
fs.writeFileSync(planFile, JSON.stringify(plan, null, 2) + '\n');
}
const audit = JSON.parse(fs.readFileSync(auditFile, 'utf8'));
const mode = process.env.I18N_MODE || 'dry-run';
const changed = event === 'push' ? changedPaths(process.env.GITHUB_EVENT_BEFORE, plan.sourceCommit) : [];
const locale = pipelineLocale(root);
const scope = JSON.parse(fs.readFileSync(path.join(root, `translations/scope/${locale}.json`), 'utf8'));
if (event === 'workflow_dispatch' && mode === 'validate') {
assert(/\.(mdx?|tsx?)$/.test(process.env.I18N_PATH || ''),
'Validation mode needs an exact English Markdown, MDX or TypeScript source path');
assert(plan.selected.length === 1 && plan.tasks.find(task => task.id === plan.selected[0])?.sourcePath === process.env.I18N_PATH,
'Validation mode must select exactly one eligible source file');
}
const eligible = event === 'schedule' || event === 'workflow_dispatch' && ['generate', 'validate'].includes(mode)
|| event === 'push' && changed !== null && relevantPush(changed, audit, scope);
const summary = summarize(plan, reconciliation, eligible, mode, languageName(root, locale), reused);
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
else process.stdout.write(summary);
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
`can_generate=${eligible && plan.selected.length > 0}\n`
+ `source_commit=${plan.sourceCommit}\n`);
}
if (require.main === module) {
try {main();} catch (error) {console.error(error.message); process.exitCode = 1;}
}
module.exports = {relevantPush, changedPaths, summarize, rulesFor, planInputs};