feat: 重构被审查分支的选择逻辑

push 审查
  check_branches 现在只表示「要做 push 审查的分支」,不再兼作 PR 目标分支:
  - 勾选具体分支:这些分支的 push 会审查,通过后晋级到管理分支
  - 新增「任意分支」(*):所有分支的 push 都审查
  - 留空:只审 PR,不审 push

PR 审查
  始终覆盖「勾选的分支 + 管理分支」作为合并目标。
  管理分支是最终目标,不需要出现在检查分支里也能被保护。

管理分支从候选中移除
  分支选择器不再列出管理分支——它是终点,不需要再检查自己。

安全边界
  「任意分支」只扩大审查范围,不会把任意分支自动合并进管理分支;
  晋级仍要求分支被显式勾选(promotesToManaged)。

同时修正一处语义错误
  早先 branchMatches 把空列表当成「审所有分支」,导致新建仓库在用户
  还没选分支时就审查全部 push。现在空列表明确表示「只审 PR」。

测试:分支匹配矩阵重写,覆盖 push/PR/晋级/保护目标四类判定,共 20 项通过。
This commit is contained in:
2026-09-21 17:12:57 +08:00
parent 7cd2c85888
commit 216bb51c92
6 changed files with 209 additions and 67 deletions
+86 -26
View File
@@ -64,26 +64,86 @@ export function parseRepoUrl(input) {
return { owner, name, host };
}
/** Whether `refName` is one of the branches this repository reviews. */
/** Branch names are stored with or without the refs/heads/ prefix. */
function shortRef(refName) {
return String(refName || "").replace(/^refs\/heads\//, "");
}
/** The "any branch" token that widens push review to every branch. */
export const ANY_BRANCH = "*";
/**
* Branches explicitly picked for checking.
* The "any branch" token is not a branch name, so it is filtered out.
*/
export function selectedBranches(checkBranches) {
return parseList(checkBranches).filter((b) => b !== ANY_BRANCH);
}
/**
* True when pushes on every branch are reviewed.
* An empty list means "pull requests only", not "every push" — newly added
* repositories must not start reviewing pushes until branches are picked.
*/
export function reviewsAnyPush(checkBranches) {
return parseList(checkBranches).includes(ANY_BRANCH);
}
/**
* Whether a push to `refName` should be reviewed.
* Selecting "any branch" reviews everything; otherwise only the listed ones.
*/
export function branchMatches(refName, checkBranches) {
const list = parseList(checkBranches);
if (list.length === 0) return true;
const short = String(refName || "").replace(/^refs\/heads\//, "");
return list.some((b) => b === short || b === refName);
if (reviewsAnyPush(checkBranches)) return true;
const name = shortRef(refName);
return selectedBranches(checkBranches).includes(name);
}
/**
* Whether a pull request should be reviewed.
*
* The check list names the *target* branches worth protecting, so a PR counts
* when it merges INTO a checked branch. That keeps "review everything that
* lands on prd/test" working even though feature branches are never listed.
* As a fallback the head branch is also matched, so explicitly listing a
* long-lived branch still reviews pushes and PRs originating from it.
* A pull request counts when it merges INTO a checked branch, or into the
* managed branch — the managed branch is the final target, so it is protected
* without needing to be listed as a checked branch. Matching the head branch
* is kept as a fallback so explicitly listing a long-lived branch still covers
* pull requests originating from it.
*/
export function pullRequestMatches(headRef, baseRef, checkBranches) {
if (branchMatches(baseRef, checkBranches)) return true;
return branchMatches(headRef, checkBranches);
export function pullRequestMatches(headRef, baseRef, checkBranches, managedBranch) {
const target = shortRef(baseRef);
const head = shortRef(headRef);
const list = selectedBranches(checkBranches);
if (managedBranch) {
// The managed branch is the final target: always protected for pull
// requests, even though it is deliberately absent from the checked list.
if (target === shortRef(managedBranch)) return true;
return list.includes(target) || list.includes(head);
}
// No managed branch configured: an empty list falls back to reviewing all
// pull requests rather than silently reviewing none.
if (list.length === 0) return true;
return list.includes(target) || list.includes(head);
}
/**
* Whether a push to this branch should be promoted into the managed branch.
*
* Only explicitly listed branches promote. Selecting "any branch" widens
* *review* to every branch but must not auto-merge arbitrary branches into the
* managed branch, so it never promotes on its own.
*/
export function promotesToManaged(refName, checkBranches, managedBranch) {
const name = shortRef(refName);
if (!name || name === shortRef(managedBranch)) return false;
return selectedBranches(checkBranches).includes(name);
}
/** Whether `baseRef` is a branch that pull requests may be merged into. */
export function isProtectedTarget(baseRef, checkBranches, managedBranch) {
const target = shortRef(baseRef);
if (!target) return false;
if (managedBranch && target === shortRef(managedBranch)) return true;
return selectedBranches(checkBranches).includes(target);
}
function badge(comment) {
@@ -714,8 +774,8 @@ export class ReviewEngine {
// A two-stage flow (feature -> test -> prd) legitimately merges into a
// checked branch that is not the managed branch, so the gate is "is the
// target one of the branches we protect", not "is it the managed branch".
if (job.base_ref && !branchMatches(job.base_ref, repo.check_branches)) {
appendLog(`auto-merge skipped: PR targets ${job.base_ref}, which is not in check_branches (${repo.check_branches})`);
if (job.base_ref && !isProtectedTarget(job.base_ref, repo.check_branches, repo.managed_branch)) {
appendLog(`auto-merge skipped: PR targets ${job.base_ref}, which is neither the managed branch nor a checked branch`);
return false;
}
if (reviewIncomplete) {
@@ -798,21 +858,21 @@ export class ReviewEngine {
appendLog(`auto-merge skipped: commit status is ${statusState}`);
return false;
}
if (!branchMatches(job.ref_name, repo.check_branches)) {
appendLog(`auto-merge skipped: ${job.ref_name} is not a checked branch`);
return false;
}
// Walk up the chain: test -> prd. The managed branch is the top, so a push
// to it has nowhere to go.
const managed = repo.managed_branch;
if (job.ref_name === managed) {
appendLog(`auto-merge skipped: ${job.ref_name} is the managed branch`);
// Only explicitly listed branches promote. "任意分支" widens review to every
// branch but must not auto-merge arbitrary branches into the managed one.
if (!promotesToManaged(job.ref_name, repo.check_branches, managed)) {
if (reviewsAnyPush(repo.check_branches)) {
appendLog(`auto-merge skipped: ${job.ref_name} is not an explicitly checked branch`);
} else {
appendLog(`auto-merge skipped: ${job.ref_name} is not a checked branch`);
}
return false;
}
// The managed branch is the top of the chain and has nowhere to promote to.
const target = managed;
if (!branchMatches(target, repo.check_branches) && target !== managed) {
appendLog(`auto-merge skipped: ${target} is not a checked branch`);
if (!target) {
appendLog("auto-merge skipped: no managed branch configured");
return false;
}