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; }
}
+99
View File
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { DEFAULT_REPOSITORIES, createReport, inspectRepository, renderMarkdown } from './report-unmerged-branches.mjs';
function fixture(t) {
const path = mkdtempSync(join(tmpdir(), 'easyai-branch-report-'));
t.after(() => rmSync(path, { recursive: true, force: true }));
const git = (...args) => execFileSync('git', args, { cwd: path, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
git('init', '-b', 'master');
git('config', 'user.name', '分支验收');
git('config', 'user.email', 'branch-test@example.invalid');
const commit = (file, contents, subject) => {
writeFileSync(join(path, file), contents); git('add', file); git('commit', '-m', subject);
return git('rev-parse', 'HEAD');
};
const base = commit('base.txt', 'base\n', 'chore: 基线');
git('checkout', '-b', 'equivalent');
const original = commit('equivalent.txt', 'integrated\n', 'fix: 原始修复');
git('checkout', 'master'); git('cherry-pick', '--no-commit', original); git('commit', '-m', 'fix: 等价集成修复');
const target = git('rev-parse', 'HEAD');
assert.notEqual(original, target);
git('update-ref', 'refs/remotes/origin/master', target);
git('update-ref', 'refs/remotes/origin/equivalent', original);
git('update-ref', 'refs/remotes/origin/merged', base);
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master');
git('checkout', '-b', 'feature');
const unmerged = commit('feature.txt', 'new\n', 'feat: 未合入能力');
git('update-ref', 'refs/remotes/origin/feature', unmerged);
return { path, git, original, target, unmerged, repository: { name: 'web', label: '前端', path, targetBranch: 'master' } };
}
test('默认前端发布和分支检查均以 master 为主分支', () => {
assert.equal(DEFAULT_REPOSITORIES.find(x => x.name === 'web').targetBranch, 'master');
});
test('报告真实未合入提交,排除已合入历史和 cherry-pick 等价补丁,且不改工作区', t => {
const f = fixture(t);
writeFileSync(join(f.path, 'base.txt'), '用户未提交编辑\n');
const before = f.git('status', '--porcelain');
const result = inspectRepository(f.repository);
assert.equal(result.status, 'complete');
assert.equal(result.summary.checkedBranches, 3);
assert.equal(result.summary.uniqueUnmergedCommits, 1);
assert.equal(result.branches.find(x => x.name === 'feature').unmergedCommits[0].sha, f.unmerged);
assert.equal(result.branches.find(x => x.name === 'equivalent').status, 'equivalent-integrated');
assert.equal(result.branches.find(x => x.name === 'equivalent').equivalentCommits[0].sha, f.original);
assert.equal(result.branches.find(x => x.name === 'merged').status, 'merged');
assert.equal(f.git('status', '--porcelain'), before);
assert.equal(f.git('rev-parse', 'HEAD'), f.unmerged);
});
test('相同提交位于多个分支时,仓库提交总数去重并保留各分支', t => {
const f = fixture(t);
f.git('update-ref', 'refs/remotes/origin/feature-copy', f.unmerged);
const result = inspectRepository(f.repository);
assert.equal(result.summary.branchesNeedingReview, 2);
assert.equal(result.summary.uniqueUnmergedCommits, 1);
});
test('仅合并提交尚未进入主分支也标为待核对,不冒充普通功能提交', t => {
const f = fixture(t);
const tree = f.git('rev-parse', f.target + '^{tree}');
const merge = f.git('commit-tree', tree, '-p', f.target, '-p', f.original, '-m', 'chore: 合并历史待核对');
f.git('update-ref', 'refs/remotes/origin/integration', merge);
const result = inspectRepository(f.repository);
const branch = result.branches.find(x => x.name === 'integration');
assert.equal(branch.status, 'needs-review');
assert.equal(branch.unmergedCommits.length, 0);
assert.equal(branch.mergeCommits[0].sha, merge);
assert.match(renderMarkdown(createReport([f.repository])), /合并提交.*合并历史待核对/);
});
test('主分支缺失时明确检查失败,CLI 保存证据且返回非零', t => {
const f = fixture(t);
const repository = { ...f.repository, targetBranch: 'missing' };
const result = createReport([repository]);
assert.equal(result.status, 'incomplete');
assert.equal(result.repositories[0].errors.length, 1);
assert.doesNotMatch(renderMarkdown(result), /其他远端分支无未合入改动/);
const config = join(f.path, 'repos.json'), output = join(f.path, 'report');
writeFileSync(config, JSON.stringify([repository]));
const cli = spawnSync(process.execPath, [fileURLToPath(new URL('./report-unmerged-branches.mjs', import.meta.url)), '--config', config, '--output-dir', output], { encoding: 'utf8' });
assert.equal(cli.status, 1);
assert.equal(JSON.parse(readFileSync(join(output, 'unmerged-branches.json'), 'utf8')).status, 'incomplete');
assert.match(readFileSync(join(output, 'unmerged-branches.md'), 'utf8'), /检查失败/);
});
test('fetch 失败不能把旧 refs 当成本日检查成功', t => {
const f = fixture(t);
const result = inspectRepository(f.repository, { fetch: true });
assert.equal(result.status, 'incomplete');
assert.equal(result.summary.checkedBranches, 0);
assert.match(result.errors[0].message, /origin/);
});