feat(release): 增加每日未合入分支报告并以 master 检查前端

This commit is contained in:
2026-10-12 20:25:11 +08:00
parent 014b36455c
commit 3cf079f527
2 changed files with 262 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
export const DEFAULT_REPOSITORIES = [
{ name: 'backend', label: '后端', path: join(homedir(), 'projects/easyai-backend'), targetBranch: 'main' },
{ name: 'web', label: '前端', path: join(homedir(), 'projects/easyai-main-web'), targetBranch: 'master' },
{ name: 'doc', label: '文档', path: join(homedir(), 'projects/easyai-doc'), targetBranch: 'master' },
{ name: 'deploy', label: '部署', path: join(homedir(), 'projects/easyai'), targetBranch: 'main' },
];
function git(path, ...args) {
return execFileSync('git', args, {
cwd: path, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 120_000,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' },
}).trim();
}
function errorMessage(error) {
return String(error.stderr || error.message).trim().replace(/https?:\/\/[^\s/@]+@/g, 'https://[redacted]@');
}
function commits(path, range, filter) {
const output = git(path, 'log', filter, '--format=%H%x00%cI%x00%an%x00%s', range);
return output ? output.split('\n').map(line => {
const [sha, committedAt, author, subject] = line.split('\0');
return { sha, shortSha: sha.slice(0, 8), committedAt, author, subject };
}) : [];
}
export function inspectRepository(repository, { fetch = false } = {}) {
const remote = repository.remote || 'origin';
const targetRef = `refs/remotes/${remote}/${repository.targetBranch}`;
const result = { ...repository, remote, targetRef, branches: [], errors: [], status: 'complete' };
try {
if (fetch) git(repository.path, 'fetch', '--prune', remote, `+refs/heads/*:refs/remotes/${remote}/*`);
result.targetSha = git(repository.path, 'rev-parse', '--verify', `${targetRef}^{commit}`);
const output = git(repository.path, 'for-each-ref', '--format=%(refname)%00%(objectname)%00%(symref)', `refs/remotes/${remote}/`);
const cache = new Map();
const knownEquivalent = new Set();
for (const line of output.split('\n').filter(Boolean)) {
const [ref, sha, symbolicRef] = line.split('\0');
if (symbolicRef || ref === targetRef) continue;
const branch = { name: ref.slice(`refs/remotes/${remote}/`.length), ref, sha };
try {
let comparison = cache.get(sha);
if (!comparison) {
const range = `${targetRef}..${ref}`;
const ahead = Number(git(repository.path, 'rev-list', '--count', range));
comparison = { ahead, unmergedCommits: [], equivalentCommits: [], mergeCommits: [] };
if (ahead > 0) {
const cherry = git(repository.path, 'cherry', targetRef, ref);
const equivalence = new Map(cherry.split('\n').filter(Boolean).map(line => [line.slice(2), line[0]]));
comparison.mergeCommits = commits(repository.path, range, '--merges');
for (const commit of commits(repository.path, range, '--no-merges')) {
if (equivalence.get(commit.sha) === '-') knownEquivalent.add(commit.sha);
// If the branch merged the target, Git's merge base can hide older
// upstream patches. Compare the individual incoming commit too.
if (comparison.mergeCommits.length && !knownEquivalent.has(commit.sha)) {
const isolated = git(repository.path, 'cherry', targetRef, commit.sha);
for (const line of isolated.split('\n')) if (line.startsWith('- ')) knownEquivalent.add(line.slice(2));
}
// A changed SHA alone does not mean the change is absent from the target branch.
const list = knownEquivalent.has(commit.sha) ? comparison.equivalentCommits : comparison.unmergedCommits;
list.push(commit);
}
}
comparison.status = comparison.unmergedCommits.length || comparison.mergeCommits.length
? 'needs-review' : comparison.equivalentCommits.length ? 'equivalent-integrated' : 'merged';
cache.set(sha, comparison);
}
result.branches.push({ ...branch, ...comparison });
} catch (error) {
result.errors.push({ branch: branch.name, message: errorMessage(error) });
}
}
} catch (error) {
result.errors.push({ message: errorMessage(error) });
}
if (result.errors.length) result.status = 'incomplete';
result.branches.sort((a, b) => b.unmergedCommits.length - a.unmergedCommits.length || a.name.localeCompare(b.name));
result.summary = {
checkedBranches: result.branches.length,
branchesNeedingReview: result.branches.filter(x => x.status === 'needs-review').length,
uniqueUnmergedCommits: new Set(result.branches.flatMap(x => x.unmergedCommits.map(c => c.sha))).size,
uniqueMergeCommits: new Set(result.branches.flatMap(x => x.mergeCommits.map(c => c.sha))).size,
equivalentOnlyBranches: result.branches.filter(x => x.status === 'equivalent-integrated').length,
};
return result;
}
export function createReport(repositories = DEFAULT_REPOSITORIES, options = {}) {
const results = repositories.map(repository => inspectRepository(repository, options));
return {
schemaVersion: 1, checkedAt: new Date().toISOString(), timezone: 'Asia/Shanghai',
fetchedByScript: Boolean(options.fetch), status: results.every(x => x.status === 'complete') ? 'complete' : 'incomplete',
repositories: results,
};
}
function markdown(value) { return String(value).replace(/[\\`*_[\]<>|]/g, '\\$&').replace(/[\r\n]/g, ' '); }
export function renderMarkdown(report, limit = 10) {
const time = new Date(report.checkedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
const lines = ['# 每日分支合入检查', '', `检查时间:${time}(Asia/Shanghai)。`, '',
'检查四库 origin 远端分支。未合入不代表未上线;补丁等价的提交单独标记,合并提交单独列为待核对。squash 或重写后无法匹配的提交仍需按实际代码核对。此报告不自动合并或发布支线内容。', '',
'| 仓库 | 发布主分支 | 已检查分支 | 待核对分支 | 未合入普通提交(去重) | 待核对合并提交(去重) | 状态 |',
'| --- | --- | ---: | ---: | ---: | ---: | --- |'];
for (const repo of report.repositories) {
const s = repo.summary;
lines.push(`| ${markdown(repo.label || repo.name)} | ${markdown(repo.remote + '/' + repo.targetBranch)} | ${s.checkedBranches} | ${s.branchesNeedingReview} | ${s.uniqueUnmergedCommits} | ${s.uniqueMergeCommits} | ${repo.status === 'complete' ? '完成' : '检查不完整'} |`);
}
for (const repo of report.repositories) {
lines.push('', `## ${markdown(repo.label || repo.name)}`, '');
if (repo.errors.length) for (const error of repo.errors) lines.push(`- 检查失败${error.branch ? '(' + markdown(error.branch) + ')' : ''}:${markdown(error.message)}`);
const pending = repo.branches.filter(x => x.status === 'needs-review');
if (!pending.length && !repo.errors.length) lines.push('其他远端分支无未合入改动。');
for (const branch of pending) {
lines.push(`- **${markdown(branch.name)}**:${branch.unmergedCommits.length} 个未合入普通提交;${branch.equivalentCommits.length} 个补丁等价提交已集成;${branch.mergeCommits.length} 个合并提交待核对。`);
for (const commit of branch.unmergedCommits.slice(0, limit)) lines.push(` - \`${commit.shortSha}\` ${markdown(commit.subject)}(${markdown(commit.author)},${commit.committedAt})`);
for (const commit of branch.mergeCommits.slice(0, limit)) lines.push(` - 合并提交 \`${commit.shortSha}\` ${markdown(commit.subject)}`);
if (branch.unmergedCommits.length > limit || branch.mergeCommits.length > limit) lines.push(' - 其余提交见同目录 unmerged-branches.json。');
}
const equivalent = repo.branches.filter(x => x.status === 'equivalent-integrated');
if (equivalent.length) lines.push('', '仅 SHA/历史不同、补丁已等价集成的分支:' + equivalent.map(x => markdown(x.name)).join('、') + '。');
}
return lines.join('\n') + '\n';
}
function main() {
const args = process.argv.slice(2);
let outputDir, config, fetch = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--fetch') fetch = true;
else if (args[i] === '--output-dir' || args[i] === '--config') {
const option = args[i], value = args[++i];
if (!value || value.startsWith('--')) throw new Error(`${option} 缺少参数`);
if (option === '--output-dir') outputDir = resolve(value); else config = value;
} else if (args[i] === '--help') {
console.log('node report-unmerged-branches.mjs [--fetch] [--output-dir 目录] [--config 仓库配置.json]\n默认只读已 fetch 的远端 refs;--fetch 同步四库全部 origin 分支并清除失效远端 refs。配置为包含 name、path、targetBranch 的数组。');
return;
} else throw new Error(`未知参数:${args[i]}`);
}
const repositories = config ? JSON.parse(readFileSync(config, 'utf8')) : DEFAULT_REPOSITORIES;
if (!Array.isArray(repositories) || !repositories.length || repositories.some(x => !x.name || !x.path || !x.targetBranch)) throw new Error('仓库配置必须是非空数组,包含 name、path、targetBranch');
const report = createReport(repositories, { fetch });
const text = renderMarkdown(report);
if (outputDir) {
mkdirSync(outputDir, { recursive: true, mode: 0o700 });
writeFileSync(join(outputDir, 'unmerged-branches.json'), JSON.stringify(report, null, 2) + '\n', { mode: 0o600 });
writeFileSync(join(outputDir, 'unmerged-branches.md'), text, { mode: 0o600 });
}
console.log(text);
if (report.status !== 'complete') process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try { main(); } catch (error) { console.error(errorMessage(error)); process.exitCode = 1; }
}