push 审查 check_branches 现在只表示「要做 push 审查的分支」,不再兼作 PR 目标分支: - 勾选具体分支:这些分支的 push 会审查,通过后晋级到管理分支 - 新增「任意分支」(*):所有分支的 push 都审查 - 留空:只审 PR,不审 push PR 审查 始终覆盖「勾选的分支 + 管理分支」作为合并目标。 管理分支是最终目标,不需要出现在检查分支里也能被保护。 管理分支从候选中移除 分支选择器不再列出管理分支——它是终点,不需要再检查自己。 安全边界 「任意分支」只扩大审查范围,不会把任意分支自动合并进管理分支; 晋级仍要求分支被显式勾选(promotesToManaged)。 同时修正一处语义错误 早先 branchMatches 把空列表当成「审所有分支」,导致新建仓库在用户 还没选分支时就审查全部 push。现在空列表明确表示「只审 PR」。 测试:分支匹配矩阵重写,覆盖 push/PR/晋级/保护目标四类判定,共 20 项通过。
289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
/**
|
|
* Unit tests for diff parsing, branch matching, and job claiming.
|
|
* Run with: node --test tests/
|
|
*/
|
|
import { strict as assert } from "node:assert";
|
|
import { test } from "node:test";
|
|
import { rmSync } from "node:fs";
|
|
|
|
import { parseUnifiedDiff, addedLineNumbers, diffLineNumbers, pickAnchorLine } from "../app/lib/diff.js";
|
|
import {
|
|
branchMatches, parseRepoUrl, pullRequestMatches,
|
|
promotesToManaged, isProtectedTarget, reviewsAnyPush, selectedBranches,
|
|
} from "../app/lib/review.js";
|
|
import { severityAtLeast, parseList } from "../app/lib/ocr.js";
|
|
import { normalizeBaseUrl } from "../app/lib/gitea.js";
|
|
import {
|
|
openDatabase, upsertRepository, enqueueJob, claimNextJob, updateJob,
|
|
getBranchState, setBranchState, findJobBySha, recordDelivery, jobStats,
|
|
} from "../app/lib/db.js";
|
|
|
|
// Hunk headers must agree with the body line counts: the parser advances the
|
|
// new-file cursor from the header, so an inconsistent fixture would silently
|
|
// shift every later line number.
|
|
const SAMPLE = `diff --git a/src/app.js b/src/app.js
|
|
index 1111111..2222222 100644
|
|
--- a/src/app.js
|
|
+++ b/src/app.js
|
|
@@ -1,4 +1,5 @@
|
|
const a = 1;
|
|
-const b = 2;
|
|
+const b = 3;
|
|
+const c = 4;
|
|
|
|
module.exports = { a, b };
|
|
@@ -20,3 +21,4 @@ function later() {
|
|
const x = 1;
|
|
return 1;
|
|
}
|
|
+const d = 5;
|
|
diff --git a/new.txt b/new.txt
|
|
new file mode 100644
|
|
--- /dev/null
|
|
+++ b/new.txt
|
|
@@ -0,0 +1,2 @@
|
|
+hello
|
|
+world
|
|
diff --git a/gone.txt b/gone.txt
|
|
deleted file mode 100644
|
|
--- a/gone.txt
|
|
+++ /dev/null
|
|
@@ -1,1 +0,0 @@
|
|
-bye
|
|
`;
|
|
|
|
test("parseUnifiedDiff tracks paths, status and hunk line maps", () => {
|
|
const files = parseUnifiedDiff(SAMPLE);
|
|
assert.deepEqual([...files.keys()], ["src/app.js", "new.txt", "gone.txt"]);
|
|
assert.equal(files.get("src/app.js").status, "modified");
|
|
assert.equal(files.get("new.txt").status, "added");
|
|
assert.equal(files.get("gone.txt").status, "deleted");
|
|
assert.equal(files.get("src/app.js").hunks.length, 2);
|
|
});
|
|
|
|
test("addedLineNumbers only reports added new-file lines", () => {
|
|
const files = parseUnifiedDiff(SAMPLE);
|
|
assert.deepEqual([...addedLineNumbers(files.get("src/app.js"))].sort((a, b) => a - b), [2, 3, 24]);
|
|
assert.deepEqual([...addedLineNumbers(files.get("new.txt"))].sort((a, b) => a - b), [1, 2]);
|
|
assert.deepEqual([...addedLineNumbers(files.get("gone.txt"))], []);
|
|
});
|
|
|
|
test("diffLineNumbers includes context lines", () => {
|
|
const files = parseUnifiedDiff(SAMPLE);
|
|
const lines = diffLineNumbers(files.get("src/app.js"));
|
|
// Context (1, 4, 5, 21, 22, 23) and added (2, 3, 24) lines are all renderable.
|
|
assert.deepEqual([...lines].sort((a, b) => a - b), [1, 2, 3, 4, 5, 21, 22, 23, 24]);
|
|
// The deleted old-file line 2 has no new-file counterpart.
|
|
assert.ok(!lines.has(6));
|
|
});
|
|
|
|
test("pickAnchorLine prefers added lines inside the hunk", () => {
|
|
const files = parseUnifiedDiff(SAMPLE);
|
|
const entry = files.get("src/app.js");
|
|
assert.deepEqual(pickAnchorLine(entry, 2, 3), { line: 2, inDiff: true });
|
|
assert.deepEqual(pickAnchorLine(entry, 3, 3), { line: 3, inDiff: true });
|
|
assert.deepEqual(pickAnchorLine(entry, 24, 24), { line: 24, inDiff: true });
|
|
});
|
|
|
|
test("pickAnchorLine falls back to context then to the start line", () => {
|
|
const files = parseUnifiedDiff(SAMPLE);
|
|
const entry = files.get("src/app.js");
|
|
// Line 1 is context in the hunk, so it is still rendered inline.
|
|
assert.deepEqual(pickAnchorLine(entry, 1, 1), { line: 1, inDiff: true });
|
|
// Line 999 is outside every hunk: keep it, but flag it as not inline.
|
|
assert.deepEqual(pickAnchorLine(entry, 999, 999), { line: 999, inDiff: false });
|
|
});
|
|
|
|
test("branchMatches reviews pushes only on the listed branches", () => {
|
|
assert.ok(branchMatches("test", "test,dev"), "listed push");
|
|
assert.ok(branchMatches("refs/heads/test", "test,dev"), "refs/heads prefix is stripped");
|
|
assert.ok(!branchMatches("feature/x", "test,dev"), "unlisted push");
|
|
assert.ok(!branchMatches("prd", "test,dev"), "the managed branch is not implicitly checked");
|
|
// An empty list means "pull requests only", not "every push".
|
|
assert.ok(!branchMatches("anything", ""));
|
|
// Globs were never supported and a literal `*` is now the wildcard token.
|
|
assert.ok(!branchMatches("release/2.0", "release/*"), "globs are not patterns");
|
|
});
|
|
|
|
test("branchMatches treats the wildcard token as every branch", () => {
|
|
assert.ok(reviewsAnyPush("*"));
|
|
assert.ok(reviewsAnyPush("*,test"));
|
|
assert.ok(!reviewsAnyPush("test,dev"));
|
|
assert.ok(!reviewsAnyPush(""), "empty list means pull requests only");
|
|
assert.ok(branchMatches("feature/whatever", "*"), "wildcard reviews any push");
|
|
assert.ok(branchMatches("feature/whatever", "*,test"), "wildcard plus explicit branches");
|
|
assert.deepEqual(selectedBranches("*,test"), ["test"], "wildcard is not a branch name");
|
|
});
|
|
|
|
test("pullRequestMatches covers checked branches and the managed branch", () => {
|
|
const checks = "test,dev";
|
|
const managed = "prd";
|
|
assert.ok(pullRequestMatches("fix/x", "prd", checks, managed), "PR into the managed branch");
|
|
assert.ok(pullRequestMatches("fix/x", "test", checks, managed), "PR into a checked branch");
|
|
assert.ok(pullRequestMatches("test", "prd", checks, managed), "promotion PR test -> prd");
|
|
assert.ok(!pullRequestMatches("fix/x", "other", checks, managed), "PR into an unprotected branch");
|
|
assert.ok(pullRequestMatches("test", "other", checks, managed), "head branch explicitly listed");
|
|
// The managed branch is protected even though it is absent from the list.
|
|
assert.ok(pullRequestMatches("anything", "prd", "", "prd"), "empty list still protects the managed branch");
|
|
assert.ok(pullRequestMatches("anything", "other", "", ""), "no managed branch and empty list reviews all");
|
|
});
|
|
|
|
test("promotesToManaged only promotes explicitly checked branches", () => {
|
|
const checks = "test,dev";
|
|
const managed = "prd";
|
|
assert.ok(promotesToManaged("test", checks, managed), "checked branch promotes");
|
|
assert.ok(promotesToManaged("dev", checks, managed), "checked branch promotes");
|
|
assert.ok(!promotesToManaged("feature/x", checks, managed), "unlisted branch never promotes");
|
|
assert.ok(!promotesToManaged("prd", checks, managed), "the managed branch has nowhere to go");
|
|
// "任意分支" widens review but must not auto-merge arbitrary branches.
|
|
assert.ok(!promotesToManaged("feature/x", "*,test", managed), "wildcard alone does not promote");
|
|
assert.ok(promotesToManaged("test", "*,test", managed), "wildcard keeps explicit promotions");
|
|
});
|
|
|
|
test("isProtectedTarget accepts the managed branch and checked branches", () => {
|
|
const checks = "test,dev";
|
|
const managed = "prd";
|
|
assert.ok(isProtectedTarget("prd", checks, managed), "managed branch");
|
|
assert.ok(isProtectedTarget("test", checks, managed), "checked branch");
|
|
assert.ok(!isProtectedTarget("other", checks, managed), "unprotected branch");
|
|
assert.ok(!isProtectedTarget("", checks, managed), "empty ref");
|
|
assert.ok(isProtectedTarget("refs/heads/prd", checks, managed), "prefix is stripped");
|
|
});
|
|
|
|
test("parseRepoUrl derives owner and name from every supported URL form", () => {
|
|
const cases = [
|
|
["http://192.168.31.51/kgod/myrepo.git", "kgod", "myrepo"],
|
|
["http://192.168.31.51/kgod/myrepo", "kgod", "myrepo"],
|
|
["https://git.example.com/group/sub/repo.git", "group/sub", "repo"],
|
|
["ssh://git@git.example.com:2222/kgod/myrepo.git", "kgod", "myrepo"],
|
|
["git@git.example.com:kgod/myrepo.git", "kgod", "myrepo"],
|
|
];
|
|
for (const [url, owner, name] of cases) {
|
|
const parsed = parseRepoUrl(url);
|
|
assert.equal(parsed.owner, owner, url);
|
|
assert.equal(parsed.name, name, url);
|
|
}
|
|
});
|
|
|
|
test("parseRepoUrl rejects unusable input", () => {
|
|
assert.throws(() => parseRepoUrl(""), /Git 地址/);
|
|
assert.throws(() => parseRepoUrl("not a url"), /无法识别|缺少/);
|
|
assert.throws(() => parseRepoUrl("http://host/onlyowner"), /缺少/);
|
|
});
|
|
|
|
test("severityAtLeast compares known severities and rejects unknown ones", () => {
|
|
assert.ok(severityAtLeast("critical", "high"));
|
|
assert.ok(severityAtLeast("high", "high"));
|
|
assert.ok(!severityAtLeast("medium", "high"));
|
|
assert.ok(!severityAtLeast("", "low"));
|
|
assert.ok(!severityAtLeast("bogus", "low"));
|
|
});
|
|
|
|
test("parseList trims and drops empties", () => {
|
|
assert.deepEqual(parseList("critical, high ,,low"), ["critical", "high", "low"]);
|
|
assert.deepEqual(parseList(""), []);
|
|
assert.deepEqual(parseList(null), []);
|
|
});
|
|
|
|
test("normalizeBaseUrl accepts root and api/v1 forms", () => {
|
|
for (const input of ["http://h", "http://h/", "http://h/api/v1", "http://h/api/v1/"]) {
|
|
assert.equal(normalizeBaseUrl(input), "http://h");
|
|
}
|
|
});
|
|
|
|
function tempDb(name) {
|
|
const path = `F:\\tmp\\cr-test-${name}-${process.pid}.db`;
|
|
for (const suffix of ["", "-wal", "-shm"]) rmSync(path + suffix, { force: true });
|
|
const db = openDatabase(path);
|
|
return {
|
|
db,
|
|
cleanup() {
|
|
db.close();
|
|
for (const suffix of ["", "-wal", "-shm"]) rmSync(path + suffix, { force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
test("job queue claims once and records terminal state", () => {
|
|
const { db, cleanup } = tempDb("queue");
|
|
try {
|
|
const repo = upsertRepository(db, { owner: "o", name: "r" });
|
|
const jobId = enqueueJob(db, { repoId: repo.id, trigger: "push", refName: "main", toSha: "a".repeat(40) });
|
|
|
|
const first = claimNextJob(db);
|
|
assert.equal(first.id, jobId);
|
|
assert.equal(first.status, "running");
|
|
assert.equal(first.attempts, 1);
|
|
assert.equal(claimNextJob(db), null, "a claimed job is not handed out twice");
|
|
|
|
updateJob(db, jobId, { status: "succeeded", findings: 2, blocking: 1 });
|
|
assert.deepEqual(jobStats(db), { queued: 0, running: 0, succeeded: 1, failed: 0, skipped: 0 });
|
|
assert.equal(findJobBySha(db, repo.id, "a".repeat(40)), undefined, "finished jobs are not treated as in-flight");
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("findJobBySha blocks duplicate in-flight reviews", () => {
|
|
const { db, cleanup } = tempDb("dedupe");
|
|
try {
|
|
const repo = upsertRepository(db, { owner: "o", name: "r" });
|
|
const sha = "b".repeat(40);
|
|
enqueueJob(db, { repoId: repo.id, trigger: "push", refName: "main", toSha: sha });
|
|
assert.ok(findJobBySha(db, repo.id, sha));
|
|
const claimed = claimNextJob(db);
|
|
assert.ok(findJobBySha(db, repo.id, sha), "running jobs still count");
|
|
updateJob(db, claimed.id, { status: "skipped" });
|
|
assert.equal(findJobBySha(db, repo.id, sha), undefined);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("webhook deliveries are deduplicated by delivery id", () => {
|
|
const { db, cleanup } = tempDb("delivery");
|
|
try {
|
|
assert.equal(recordDelivery(db, "d-1"), true);
|
|
assert.equal(recordDelivery(db, "d-1"), false);
|
|
assert.equal(recordDelivery(db, "d-2"), true);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("repository defaults and branch state round-trip", () => {
|
|
const { db, cleanup } = tempDb("repo");
|
|
try {
|
|
const repo = upsertRepository(db, { owner: "kgod", name: "demo" });
|
|
assert.equal(repo.managed_branch, "main");
|
|
assert.equal(repo.check_branches, "main");
|
|
assert.equal(repo.block_severity, "critical,high");
|
|
assert.equal(repo.review_scope, "both");
|
|
assert.equal(repo.create_issue, 1);
|
|
|
|
const updated = upsertRepository(db, { id: repo.id, auto_merge: 1, merge_method: "rebase" });
|
|
assert.equal(updated.auto_merge, 1);
|
|
assert.equal(updated.merge_method, "rebase");
|
|
|
|
assert.equal(getBranchState(db, repo.id, "main"), undefined);
|
|
setBranchState(db, repo.id, "main", "c".repeat(40));
|
|
assert.equal(getBranchState(db, repo.id, "main").last_sha, "c".repeat(40));
|
|
setBranchState(db, repo.id, "main", "d".repeat(40));
|
|
assert.equal(getBranchState(db, repo.id, "main").last_sha, "d".repeat(40));
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("upsertRepository does not clobber unset fields", () => {
|
|
const { db, cleanup } = tempDb("patch");
|
|
try {
|
|
const repo = upsertRepository(db, { owner: "o", name: "r", issue_labels: "review" });
|
|
upsertRepository(db, { id: repo.id, auto_merge: 1 });
|
|
const after = db.prepare("SELECT * FROM repositories WHERE id = ?").get(repo.id);
|
|
assert.equal(after.issue_labels, "review");
|
|
assert.equal(after.auto_merge, 1);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
}); |