UNPKG

@yuki-no/plugin-batch-pr

Version:

Batch PR plugin for yuki-no - Collects opened Yuki-no translation issues and creates a single pull request

236 lines (235 loc) 9.9 kB
import { FILE_HEADER_PREFIX } from '../constants'; import { isBinaryFile } from './isBinaryFile'; import { resolveFileNameWithRootDir } from './resolveFileNameWithRootDir'; import { createTempFilePath } from '@yuki-no/plugin-sdk/utils/common'; import { splitByNewline } from '@yuki-no/plugin-sdk/utils/input'; import { formatError, log } from '@yuki-no/plugin-sdk/utils/log'; import fs from 'node:fs'; export const createFileChanges = (headGit, hash, fileStatus, rootDir) => { const { headFileName } = fileStatus; const upstreamFileName = resolveFileNameWithRootDir(headFileName, rootDir); log('I', `createFileChanges :: Processing ${fileStatus.status} for ${headFileName}(head) ${upstreamFileName}(upstream)`); if (!shouldLineChangeProcessing(fileStatus)) { log('I', 'createFileChanges :: Using simple processing (no line changes)'); return [createSimpleFileChange(fileStatus, upstreamFileName, rootDir)]; } if (isBinaryFile(headFileName)) { log('I', 'createFileChanges :: File is binary, using binary processing'); return createBinaryFileChanges(headGit, hash, fileStatus, upstreamFileName); } log('I', 'createFileChanges :: File is text, using complex processing'); const result = [ createComplexFileChange(headGit, hash, fileStatus, upstreamFileName, rootDir), ]; log('S', `createFileChanges :: Generated ${result.length} file changes`); return result; }; const PERFECT_SIMILARITY = 100; // !['D', 'R', 'C', 'T'] const shouldLineChangeProcessing = (fileStatus) => fileStatus.status === 'M' || fileStatus.status === 'A' || ((fileStatus.status === 'R' || fileStatus.status === 'C') && fileStatus.similarity < PERFECT_SIMILARITY); const createSimpleFileChange = (fileStatus, // D, R100, C100, T upstreamFileName, rootDir) => { const { status } = fileStatus; if (status === 'D') { return { type: 'delete', upstreamFileName }; } if (status === 'R' || status === 'C') { const nextUpstreamFileName = resolveFileNameWithRootDir(fileStatus.nextHeadFileName, rootDir); return { type: status === 'R' ? 'rename' : 'copy', upstreamFileName, nextUpstreamFileName, similarity: fileStatus.similarity, changes: [], }; } if (status === 'T') { return { type: 'type', upstreamFileName }; } throw new Error(`Failed to create FileChange for ${upstreamFileName}`); }; const createComplexFileChange = (headGit, hash, fileStatus, // A, M, R<100, C<100 upstreamFileName, rootDir) => { const { status, headFileName } = fileStatus; const diffString = headGit.exec(`show -U0 --format= ${hash}`); const filesDiffString = parseDiffStringByFileName(diffString); const fileDiffString = filesDiffString.find(({ fileName }) => fileName === headFileName)?.diffString; if (!fileDiffString) { throw new Error(`Failed to extract fileName from ${hash} for ${headFileName}`); } log('I', `createComplexFileChange :: Extracting changes for ${headFileName}`); if (status === 'A' || status === 'M') { return { type: 'update', upstreamFileName, changes: createLineChanges(fileDiffString), }; } if (status === 'R' || status === 'C') { const nextUpstreamFileName = resolveFileNameWithRootDir(fileStatus.nextHeadFileName, rootDir); return { type: status === 'R' ? 'rename' : 'copy', upstreamFileName, nextUpstreamFileName, similarity: fileStatus.similarity, changes: createLineChanges(fileDiffString), }; } throw new Error(`Failed to create FileChange for ${upstreamFileName}`); }; const parseDiffStringByFileName = (diffString) => diffString .split('diff --git') .slice(1) .map(str => { const fileDiffString = `diff --git${str}`; const fileNameMatch = fileDiffString.match(/diff --git a\/(.+) b\/(.+)/); if (!fileNameMatch) { throw new Error(`Failed to extract fileName`); } return { diffString: fileDiffString, fileName: fileNameMatch[1] }; }); const createLineChanges = (fileDiffString) => { const diffLines = splitByNewline(fileDiffString, false); log('I', `createLineChanges :: Processing ${diffLines.length} diff lines`); const lineChanges = []; let oldLineNumber = 0; let newLineNumber = 0; for (const diffLine of diffLines) { const parsedDiff = parseDiffLine(diffLine, oldLineNumber, newLineNumber); if (parsedDiff.type === 'skip') { continue; } if (parsedDiff.type === 'hunk-header') { oldLineNumber = parsedDiff.oldLineNumber; newLineNumber = parsedDiff.newLineNumber; continue; } const { nextNewLineNumber, nextOldLineNumber } = parsedDiff; if (parsedDiff.type === 'change') { lineChanges.push(parsedDiff.change); } oldLineNumber = nextOldLineNumber; newLineNumber = nextNewLineNumber; } log('S', `createLineChanges :: Generated ${lineChanges.length} line changes`); return lineChanges; }; // hunk header format: @@ -old_start,old_count +new_start,new_count @@ const HUNK_HEADER_REGEX = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; const parseDiffLine = (diffLine, currentOldLineNumber, currentNewLineNumber) => { if (FILE_HEADER_PREFIX.some(v => diffLine.startsWith(v))) { return { type: 'skip' }; } const hunkMatch = diffLine.match(HUNK_HEADER_REGEX); if (hunkMatch) { const oldStart = parseInt(hunkMatch[1], 10); const newStart = parseInt(hunkMatch[3], 10); return { type: 'hunk-header', oldLineNumber: oldStart, newLineNumber: newStart, }; } const isAdded = diffLine.startsWith('+'); if (isAdded) { return { type: 'change', nextOldLineNumber: currentOldLineNumber, nextNewLineNumber: currentNewLineNumber + 1, change: { type: 'insert-line', lineNumber: currentNewLineNumber, content: diffLine.substring(1), // substr '+' (format: `+<content>`) }, }; } const isDeleted = diffLine.startsWith('-'); if (isDeleted) { return { type: 'change', nextOldLineNumber: currentOldLineNumber + 1, nextNewLineNumber: currentNewLineNumber, change: { type: 'delete-line', lineNumber: currentOldLineNumber, }, }; } const isContext = diffLine.startsWith(' ') || diffLine === ''; if (isContext) { return { type: 'context', nextOldLineNumber: currentOldLineNumber + 1, nextNewLineNumber: currentNewLineNumber + 1, }; } return { type: 'skip' }; }; const SHOULD_DELETE_STATUS = ['R', 'D', 'M']; const SHOULD_ADD_STATUS = ['R', 'C', 'A', 'M']; const createBinaryFileChanges = (headGit, hash, { headFileName, status }, upstreamFileName) => { log('I', `createBinaryFileChanges :: Processing binary file ${headFileName}`); const fileChanges = []; if (SHOULD_DELETE_STATUS.includes(status)) { log('I', `createBinaryFileChanges :: Adding delete operation for ${upstreamFileName}`); fileChanges.push({ type: 'delete', upstreamFileName }); } if (!SHOULD_ADD_STATUS.includes(status)) { log('I', `createBinaryFileChanges :: Skipping add operation for status ${status}`); return fileChanges; } log('I', `createBinaryFileChanges :: Adding update operation for ${upstreamFileName}`); const blobHash = extractBlobHash(headGit, hash, headFileName); const binaryChange = extractBinaryChangeSafely(headGit, blobHash); fileChanges.push({ type: 'update', upstreamFileName, changes: binaryChange, }); log('S', `createBinaryFileChanges :: Generated ${fileChanges.length} binary changes`); return fileChanges; }; const extractBinaryChangeSafely = (headGit, blobHash) => { log('I', `extractBinaryChangeSafely :: Extracting binary data for blob ${blobHash}`); const randomTempPath = createTempFilePath(`yuki-no-binary__${Date.now()}-${Math.random()}.tmp`); try { log('I', `extractBinaryChangeSafely :: Creating temp file ${randomTempPath}`); headGit.exec(`show ${blobHash} > "${randomTempPath}"`); if (!fs.existsSync(randomTempPath)) { throw new Error(); } const buffer = fs.readFileSync(randomTempPath); log('S', `extractBinaryChangeSafely :: Successfully extracted ${buffer.length} bytes for blob ${blobHash}`); return buffer; } catch (error) { log('E', `extractBinaryChangeSafely :: Failed to extract blob ${blobHash}: ${formatError(error)}`); throw new Error(`Failed to extract binary change for blob ${blobHash} / error: ${formatError(error)}`); } finally { if (fs.existsSync(randomTempPath)) { log('I', `extractBinaryChangeSafely :: Cleaning up temp file ${randomTempPath}`); fs.unlinkSync(randomTempPath); } } }; // format: 100644 blob (blobHash) (fileName) const LS_TREE_REGEX = /^(\d+) blob ([a-f0-9]+)\t(.+)$/; const extractBlobHash = (git, hash, fileName) => { const lsTreeString = git.exec(`ls-tree -r ${hash}`); const lines = splitByNewline(lsTreeString); for (const line of lines) { const match = line.match(LS_TREE_REGEX); if (!match) { continue; } const [, , blobHash, parsedFileName] = match; if (parsedFileName === fileName) { return blobHash; } } throw new Error(`Failed to extract blob hash for ${fileName} (head-repo: ${hash})`); };