feat: 重构被审查分支的选择逻辑
push 审查 check_branches 现在只表示「要做 push 审查的分支」,不再兼作 PR 目标分支: - 勾选具体分支:这些分支的 push 会审查,通过后晋级到管理分支 - 新增「任意分支」(*):所有分支的 push 都审查 - 留空:只审 PR,不审 push PR 审查 始终覆盖「勾选的分支 + 管理分支」作为合并目标。 管理分支是最终目标,不需要出现在检查分支里也能被保护。 管理分支从候选中移除 分支选择器不再列出管理分支——它是终点,不需要再检查自己。 安全边界 「任意分支」只扩大审查范围,不会把任意分支自动合并进管理分支; 晋级仍要求分支被显式勾选(promotesToManaged)。 同时修正一处语义错误 早先 branchMatches 把空列表当成「审所有分支」,导致新建仓库在用户 还没选分支时就审查全部 push。现在空列表明确表示「只审 PR」。 测试:分支匹配矩阵重写,覆盖 push/PR/晋级/保护目标四类判定,共 20 项通过。
This commit is contained in:
+86
-26
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -17,7 +17,7 @@ import { OcrRunner } from "./lib/ocr.js";
|
||||
import { JobQueue } from "./lib/queue.js";
|
||||
import {
|
||||
ReviewEngine, SkipJob, branchMatches, parseRepoUrl, pullRequestMatches,
|
||||
PROMOTION_MARKER,
|
||||
promotesToManaged, ANY_BRANCH, PROMOTION_MARKER,
|
||||
} from "./lib/review.js";
|
||||
|
||||
const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||
@@ -296,10 +296,10 @@ async function handlePullRequest(payload, cfg) {
|
||||
if (String(pr.body || "").includes(PROMOTION_MARKER)) {
|
||||
return { queued: 0, reason: "self-created promotion pull request; already reviewed on push" };
|
||||
}
|
||||
if (!pullRequestMatches(pr.head?.ref, pr.base?.ref, repo.check_branches)) {
|
||||
if (!pullRequestMatches(pr.head?.ref, pr.base?.ref, repo.check_branches, repo.managed_branch)) {
|
||||
return {
|
||||
queued: 0,
|
||||
reason: `neither head ${pr.head?.ref} nor base ${pr.base?.ref} is in the checked branch list`,
|
||||
reason: `PR base ${pr.base?.ref} is neither the managed branch nor a checked branch`,
|
||||
};
|
||||
}
|
||||
const toSha = pr.head?.sha;
|
||||
|
||||
+50
-15
@@ -197,10 +197,14 @@ function shortUrl(url) {
|
||||
|
||||
function branchTags(list, managed) {
|
||||
const names = String(list || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (!names.length) return '<span class="tag muted">—</span>';
|
||||
return names.map((n) => n === managed
|
||||
? `<span class="tag ok">${esc(n)}</span>`
|
||||
: `<span class="tag muted">${esc(n)}</span>`).join(" ");
|
||||
if (names.includes("*")) {
|
||||
const extra = names.filter((n) => n !== "*");
|
||||
return `<span class="tag warn">任意分支</span>` +
|
||||
(extra.length ? ` ${extra.map((n) => `<span class="tag muted">${esc(n)}</span>`).join(" ")}` : "");
|
||||
}
|
||||
const own = names.filter((n) => n !== managed);
|
||||
if (!own.length) return '<span class="tag muted">仅 PR</span>';
|
||||
return own.map((n) => `<span class="tag muted">${esc(n)}</span>`).join(" ");
|
||||
}
|
||||
|
||||
function scopeLabel(scope) {
|
||||
@@ -251,30 +255,59 @@ const modal = document.getElementById("modal");
|
||||
const form = document.getElementById("repo-form");
|
||||
const branchList = document.getElementById("branch-list");
|
||||
|
||||
function selectedBranches() {
|
||||
const ANY_BRANCH = "*";
|
||||
|
||||
function allSelected() {
|
||||
return String(form.elements.check_branches.value || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function setSelectedBranches(list) {
|
||||
form.elements.check_branches.value = list.join(",");
|
||||
/** Branch names only; the "任意分支" token is not a branch. */
|
||||
function selectedBranches() {
|
||||
return allSelected().filter((b) => b !== ANY_BRANCH);
|
||||
}
|
||||
|
||||
function reviewsAnyBranch() {
|
||||
return allSelected().includes(ANY_BRANCH);
|
||||
}
|
||||
|
||||
function setSelectedBranches(list, { any = reviewsAnyBranch() } = {}) {
|
||||
const names = list.filter((b) => b && b !== ANY_BRANCH);
|
||||
form.elements.check_branches.value = (any ? [ANY_BRANCH, ...names] : names).join(",");
|
||||
}
|
||||
|
||||
function renderBranchList() {
|
||||
const chosen = new Set(selectedBranches());
|
||||
const managed = form.elements.managed_branch.value.trim();
|
||||
const any = reviewsAnyBranch();
|
||||
if (!state.branches.length) {
|
||||
branchList.innerHTML = '<span class="hint">点「加载分支」获取远端分支列表。</span>';
|
||||
return;
|
||||
}
|
||||
branchList.innerHTML = state.branches.map((b) => {
|
||||
const on = chosen.has(b);
|
||||
const label = b === managed ? `${b}(管理)` : b;
|
||||
return `<button type="button" class="${on ? "on" : ""}" data-branch="${esc(b)}">${esc(label)}</button>`;
|
||||
}).join("");
|
||||
// The managed branch is the final target and is always reviewed for pull
|
||||
// requests, so it is not offered as a checked branch.
|
||||
const selectable = state.branches.filter((b) => b !== managed);
|
||||
const parts = [
|
||||
`<button type="button" class="${any ? "on" : ""}" data-any="1">任意分支</button>`,
|
||||
];
|
||||
if (selectable.length === 0) {
|
||||
parts.push('<span class="hint">除管理分支外没有其它分支。</span>');
|
||||
} else {
|
||||
parts.push(...selectable.map((b) => {
|
||||
const on = chosen.has(b);
|
||||
return `<button type="button" class="${on ? "on" : ""}" data-branch="${esc(b)}">${esc(b)}</button>`;
|
||||
}));
|
||||
}
|
||||
branchList.innerHTML = parts.join("");
|
||||
}
|
||||
|
||||
branchList.addEventListener("click", (ev) => {
|
||||
const anyBtn = ev.target.closest("button[data-any]");
|
||||
if (anyBtn) {
|
||||
setSelectedBranches(selectedBranches(), { any: !reviewsAnyBranch() });
|
||||
renderBranchList();
|
||||
return;
|
||||
}
|
||||
const btn = ev.target.closest("button[data-branch]");
|
||||
if (!btn) return;
|
||||
const name = btn.dataset.branch;
|
||||
@@ -299,7 +332,7 @@ async function openRepo(id) {
|
||||
if (!repo) {
|
||||
if (field === "enabled" || field === "create_issue") input.checked = true;
|
||||
if (field === "managed_branch") input.value = "main";
|
||||
if (field === "check_branches") input.value = "main";
|
||||
if (field === "check_branches") input.value = "";
|
||||
continue;
|
||||
}
|
||||
if (input.type === "checkbox") input.checked = Boolean(repo[field]);
|
||||
@@ -334,7 +367,9 @@ async function loadBranches({ silent = false } = {}) {
|
||||
: await api(`/repos/${id}/discover`, { method: "POST" });
|
||||
state.branches = info.branches || [];
|
||||
if (!form.elements.managed_branch.value) form.elements.managed_branch.value = info.default_branch || "main";
|
||||
if (!form.elements.check_branches.value) form.elements.check_branches.value = state.branches.join(",");
|
||||
// Existing repositories keep their saved selection; a fresh one starts
|
||||
// with no explicit branches checked (only the managed branch is reviewed).
|
||||
if (form.elements.check_branches.value === "") setSelectedBranches([], { any: false });
|
||||
renderBranchList();
|
||||
hint.className = "hint ok";
|
||||
hint.textContent =
|
||||
@@ -496,7 +531,7 @@ document.getElementById("import-list").addEventListener("click", async (ev) => {
|
||||
body: {
|
||||
repo_url: remote.clone_url,
|
||||
managed_branch: remote.default_branch || "main",
|
||||
check_branches: remote.default_branch || "main",
|
||||
check_branches: "",
|
||||
},
|
||||
});
|
||||
// Discover immediately so the branch list is populated on first open.
|
||||
|
||||
@@ -185,15 +185,16 @@
|
||||
<button type="button" id="branch-create">克隆创建</button>
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint">所有检查通过后只合并到这一个分支;它也会出现在下面的检查分支里。</p>
|
||||
<label>检查分支(可多选)
|
||||
<p class="hint">最终目标分支。它是 PR 审查的默认合并目标,但不需要出现在下面的检查分支里。</p>
|
||||
<label>检查分支(push 审查;可多选)
|
||||
<span class="inline">
|
||||
<input name="check_branches" placeholder="main,release/1.0" />
|
||||
<input name="check_branches" placeholder="留空表示只审查 PR;* 表示任意分支" />
|
||||
<button type="button" id="branch-reload">加载分支</button>
|
||||
</span>
|
||||
</label>
|
||||
<div id="branch-list" class="branch-list"></div>
|
||||
<p class="hint">填「被保护的目标分支」。任何合并进这些分支的 PR 都会审查(功能分支不用列出来);向这些分支推送也会审查。只有指向管理分支的 PR 才会自动合并。</p>
|
||||
<p class="hint">选「任意分支」则所有分支的 push 都审查,也可只勾选指定分支。勾选的分支 push 通过后会自动晋级到管理分支;「任意分支」只扩大审查范围,不会自动合并任意分支。</p>
|
||||
<p class="hint">PR 审查始终覆盖「勾选的分支 + 管理分支」作为合并目标,与管理分支是否列在这里无关。</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
|
||||
Reference in New Issue
Block a user