blob: 3d949ba643884212c62421a382ab83d47a6a63a2 [file]
// Trusted-master automation only. The workflow supplies the requested mode and
// model credential; this module writes only validated files/state for the selected managed locale.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {execFileSync} = require('node:child_process');
const {hash, identity, languageName, pipelineLocale} = require('./state.cjs');
const {checkedTask, managedPath, safeFile} = require('./translation-engine.cjs');
const {codexAdapter} = require('./codex-adapter.cjs');
const {prepare, applyCandidates, readState, stagedBaseline, statePathForRecord, targetFor} = require('./translate-docs.cjs');
const {markerPrefixFor} = require('./automation-naming.cjs');
const {checkMaster, pageFiles} = require('./master-drift.cjs');
const root = path.resolve(__dirname, '../..');
const allowedKinds = new Set(['translate_new', 'translate_backlog', 'update']);
function relativeSource(task, scope) {
const group = scope.groups.find(item => item.pluginId === task.pluginId && item.version === task.version);
assert(group && task.sourcePath.startsWith(`${group.sourceRoot}/`), 'Invalid source root for pairing');
return task.sourcePath.slice(group.sourceRoot.length + 1);
}
function pairFor(task, tasks, scope) {
if (task.pluginId !== 'default' || !['current', '4.x'].includes(task.version)) return null;
const otherVersion = task.version === 'current' ? '4.x' : 'current';
const matches = tasks.filter(other => other.pluginId === 'default' && other.version === otherVersion
&& other.docId === task.docId && other.sourceHash === task.sourceHash
&& other.chineseSourceBlob===task.chineseSourceBlob
&& relativeSource(other, scope) === relativeSource(task, scope));
assert(matches.length <= 1, 'Ambiguous Dev/4.x source pair');
const partner = matches[0];
return partner && allowedKinds.has(partner.kind) && !partner.activePr && !partner.requiresHumanReview
? partner : null;
}
function machineOwned(task, state) {
const existing = state.data.pages.find(item => identity(item) === identity(task));
return require('./source-revisions.cjs').canGenerate(existing);
}
function copyPair(primary, partner, scope, state, rootDir = root) {
managedPath(scope, partner);
assert.equal(primary.task.sourceHash, partner.sourceHash, 'Paired source hash differs');
const targetFile = safeFile(rootDir, partner.targetPath);
const previousTarget = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : null;
const existing = state.data.pages.find(item => identity(item) === identity(partner));
assert(!previousTarget && !existing, 'Existing paired translations must be updated against their own baseline');
const source = fs.readFileSync(safeFile(rootDir, partner.sourcePath), 'utf8');
assert.equal(hash(source), partner.sourceHash, 'Paired source changed');
assert.equal(execFileSync('git', ['show', `HEAD:${partner.sourcePath}`], {cwd: rootDir, encoding: 'utf8'}), source,
'Paired source has uncommitted changes');
const record = {...primary.record, pluginId: partner.pluginId, version: partner.version,
docId: partner.docId, sourcePath: partner.sourcePath, targetPath: partner.targetPath,
sourceHash: partner.sourceHash};
if(require('./source-revisions.cjs').enabled(rootDir))record.sourceRevisions=require('./source-revisions.cjs').snapshot(rootDir,record,record.sourceCommit);
return {...primary, task: partner, targetFile, previousTarget, record,
baseline: stagedBaseline(state, partner, previousTarget),
requests: 0, reusedFrom: primary.task.id};
}
function bodyFor(manifest) {
const rows = manifest.candidates.map(item => {
const review = item.generation;
const mode = review?.mode === 'incremental'
? `incremental (${(review.sourceChange.ratio * 100).toFixed(1)}%)`
: review?.reason ? `full (${review.reason})` : 'model call';
return `| ${item.kind} | ${item.version} | [\`${item.sourcePath}\`](https://github.com/apache/doris-website/blob/${manifest.sourceCommit}/${item.sourcePath}) | \`${item.sourceHash.slice(0, 12)}\` | ${item.reusedFrom ? `copied from \`${item.reusedFrom}\`` : mode} |`;
});
const failures = manifest.failures.length ? manifest.failures.map(item => `- \`${item.id}\` (${item.sourcePath}): ${item.error}`).join('\n') : 'None.';
const warnings = manifest.candidates.filter(item => item.generation?.acceptedWithWarnings)
.map(item => {
const detail = item.generation.warnings.map(warning => `${warning.check}: ${warning.message}`)
.join('; ').replace(/\s+/g, ' ');
return `- \`${item.sourcePath}\`: third attempt retained for review. `
+ detail.slice(0, 1500) + (detail.length > 1500 ? '…' : '');
});
const master = manifest.master?.moved ? [`Master moved to \`${manifest.master.head}\` during generation; `
+ `none of its ${manifest.master.changedFiles} changed files affect this batch.`] : [];
return [`## ${manifest.language} translation candidate`, '',
`Source commit: \`${manifest.sourceCommit}\``, ...master,
`Model: \`${manifest.model}\`; glossary: \`${manifest.glossaryVersion}\``,
`Review status: machine. ${manifest.language} proofreading and the normal PR checks are still required.`,
'', '| Action | Version | English source | SHA-256 prefix | Generation |',
'| --- | --- | --- | --- | --- |', ...rows, '', '## Failed or skipped tasks', '', failures, '',
'## Candidates retained with warnings', '', ...(warnings.length ? warnings : ['None.']), '',
'## Validation', '', 'Updates at or below 30% translate only changed Markdown/MDX blocks and preserve other target blocks.',
'Change size is the sum of max(removed, added) non-empty English lines per diff hunk, divided by the larger old/new line count.',
'New documents, larger updates, glossary changes or baselines that cannot be aligned use full translation.',
'Generation uses at most three attempts per document. Deletions and code-only updates may need no model call.',
'Each retry receives the earlier validation errors. Third-attempt content issues are retained as warnings above.',
'Full attempt history and diagnostics are in generation.json and the page state records.',
'Committed source hashes, managed paths and target/state hashes are checked before applying candidates.',
'Master is checked before each model call and before publishing: unrelated merges are allowed, a page changed on master is dropped, and a translation configuration or tooling change discards the batch.',
'The publisher runs the shared PR translation-state, syntax and internal-link checks before pushing.',
'The normal PR build and human proofreading are still required before merging.',
'', `${markerPrefixFor(manifest.language)}PENDING -->`, ''].join('\n');
}
async function generate({plan, reconciliation, maxPages = 10, target, adapter, glossary, rootDir = root,
ensureFresh = (sourceCommit, pages) => checkMaster({root: rootDir, sourceCommit, locale: target.locale, pages}),
output}) {
assert(Number.isInteger(maxPages) && maxPages > 0 && maxPages <= 20, 'Invalid page limit');
assert(plan.schemaVersion === 1 && plan.sourceCommit === execFileSync('git', ['rev-parse', 'HEAD'],
{cwd: rootDir, encoding: 'utf8'}).trim(), 'Plan does not match trusted checkout');
const scope = JSON.parse(fs.readFileSync(path.join(rootDir, `translations/scope/${target.locale}.json`), 'utf8'));
const state = readState(rootDir, target.locale);
const blocked = new Set(reconciliation.blockedTargets);
const active = new Set((reconciliation.active?.candidates ?? []).map(identity));
const candidates = [], failures = [], consumed = new Set();
// Master is checked before the first model call, before each page and after
// the last one, so no model call goes to a page master has already changed.
const pages = new Map();
for (const task of plan.selected.map(id => plan.tasks.find(item => item.id === id)).filter(Boolean)) {
for (const item of [task, pairFor(task, plan.tasks, scope)].filter(Boolean))
pages.set(item.id, pageFiles(item, target.locale));
}
const stale = new Set();
let drift = {moved: false};
const refresh = () => {
drift = ensureFresh(plan.sourceCommit, pages) ?? drift;
for (const id of drift.affected ?? []) stale.add(id);
};
const staleFailure = task => ({id: task.id, sourcePath: task.sourcePath,
error: 'master changed this page after the source commit; it stays pending for the next run'});
refresh();
for (const id of plan.selected) {
if (candidates.length >= maxPages) break;
const task = plan.tasks.find(item => item.id === id);
if (!task || consumed.has(id) || task.activePr || active.has(identity(task))
|| blocked.has(task.targetPath)) continue;
consumed.add(id);
if (!machineOwned(task, state)) {
failures.push({id, sourcePath: task.sourcePath, error: 'Human maintained target requires manual review'});
continue;
}
let partner = pairFor(task, plan.tasks, scope);
if (partner && (active.has(identity(partner)) || blocked.has(partner.targetPath) || !machineOwned(partner, state)
|| state.data.pages.some(record => identity(record) === identity(partner))
|| candidates.length + 2 > maxPages)) partner = null;
// Outside the per-page catch: a batch-wide change must stop the run.
refresh();
if (stale.has(id)) {
failures.push(staleFailure(task));
continue;
}
try {
checkedTask(plan, id, scope, rootDir);
const primary = await prepare({root: rootDir, plan, taskId: id, scope, state, adapter, glossary, target});
candidates.push(primary);
if (partner && !stale.has(partner.id)) try {
const copy = copyPair(primary, partner, scope, state, rootDir);
candidates.push(copy);
consumed.add(partner.id);
} catch (error) {
failures.push({id: partner.id, sourcePath: partner.sourcePath, error: error.message});
}
} catch (error) {
failures.push({id, sourcePath: task.sourcePath, error: error.message});
}
}
let discarded = null;
if (candidates.length) {
try {refresh();} catch (error) {discarded = error;}
// A copy shares its primary's translation, so it goes with it.
const dropped = candidates.filter(item => stale.has(item.task.id) || stale.has(item.reusedFrom));
for (const item of dropped) failures.push(staleFailure(item.task));
candidates.splice(0, candidates.length, ...candidates.filter(item => !dropped.includes(item)));
}
const manifest = {schemaVersion: 1, sourceCommit: plan.sourceCommit, provider: 'codex',
locale: target.locale, language: target.language,
model: adapter.model, glossaryVersion: glossary.version, liveCall: adapter.usedTokens > 0,
liveVerified: false, usedTokens: adapter.usedTokens,
...(drift.head ? {master: {head: drift.head, moved: drift.moved, changedFiles: drift.files.length}} : {}),
...(discarded ? {discarded: discarded.message} : {}),
candidates: candidates.map(item => ({id: item.task.id, kind: item.task.kind,
pluginId: item.task.pluginId, version: item.task.version, docId: item.task.docId,
sourcePath: item.task.sourcePath, sourceHash: item.task.sourceHash,
targetPath: item.task.targetPath, targetHash: item.record.targetHash,
statePath: statePathForRecord(state, item.record),
reusedFrom: item.reusedFrom || null, requests: item.requests,
...(item.record.generation ? {generation: item.record.generation} : {})})), failures};
fs.mkdirSync(output, {recursive: true});
fs.writeFileSync(path.join(output, 'generation.json'), JSON.stringify(manifest, null, 2) + '\n');
if (discarded) throw discarded;
if (candidates.length) {
for (const item of candidates) checkedTask({...plan, selected: [...plan.selected, item.task.id]},
item.task.id, scope, rootDir);
for (const item of candidates) {
const candidateFile = path.join(output, 'candidates', item.task.targetPath);
fs.mkdirSync(path.dirname(candidateFile), {recursive: true});
fs.writeFileSync(candidateFile, item.target);
}
applyCandidates(state, candidates);
fs.writeFileSync(path.join(output, 'pr-body.md'), bodyFor(manifest));
}
return manifest;
}
function allowGeneration(env) {
if (env.I18N_MODE === 'validate') {
return env.GITHUB_EVENT_NAME === 'workflow_dispatch'
&& /\.(mdx?|tsx?)$/.test(env.I18N_PATH || '') && env.I18N_MAX_PAGES === '1';
}
return env.I18N_MODE === 'generate';
}
async function main() {
const output = process.argv[2];
assert(output && process.argv.length === 3, 'Usage: workflow-generate.cjs <scan-directory>');
assert(allowGeneration(process.env), 'Generation requires generate mode or valid one-page validation');
const plan = JSON.parse(fs.readFileSync(path.join(output, 'plan.json'), 'utf8'));
if (process.env.I18N_MODE === 'validate') {
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 source file');
}
const reconciliation = JSON.parse(fs.readFileSync(path.join(output, 'open-pr-reconciliation.json'), 'utf8'));
const locale = pipelineLocale(root);
const target = targetFor(root, locale);
const glossary = JSON.parse(fs.readFileSync(path.join(root, `translations/glossary/${locale}.json`), 'utf8'));
assert(process.env.CODEX_BIN && fs.existsSync(process.env.CODEX_BIN), 'Pinned Codex runtime is required');
const adapter = codexAdapter({model: process.env.I18N_CODEX_MODEL || 'gpt-6-sol', language: languageName(root, locale), locale,
codexBin: process.env.CODEX_BIN, codexHome: process.env.CODEX_HOME});
const manifest = await generate({plan, reconciliation, maxPages: Number(process.env.I18N_MAX_PAGES || '10'),
target, adapter, glossary, output});
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
`created=${manifest.candidates.length > 0}\n`);
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY,
`\nGenerated ${manifest.candidates.length} ${manifest.language} page candidates at ${manifest.sourceCommit}; `
+ `${manifest.failures.length} failed or need manual handling. No translation is merged yet.\n`
+ (manifest.master?.moved ? `Master moved to ${manifest.master.head} meanwhile; none of its `
+ `${manifest.master.changedFiles} changed files affect the remaining candidates.\n` : ''));
process.stdout.write(JSON.stringify({candidates: manifest.candidates.length,
failures: manifest.failures.length, usedTokens: manifest.usedTokens}) + '\n');
if (process.env.I18N_MODE === 'validate' && manifest.candidates.length === 0)
process.exitCode = 1;
}
if (require.main === module) main().catch(error => {console.error(error.message); process.exitCode = 1;});
module.exports = {pairFor, machineOwned, copyPair, bodyFor, generate, allowGeneration};