Files
git-codereview/tests/core.test.js
T
kgod a0b15fb2d9 feat: 打通 推送→审查→晋级→关闭 Issue 的完整闭环
直接推送也能晋级
  往受保护分支直接推代码(没有 PR)时,审查通过后自动开一个
  <分支> → <管理分支> 的 PR 并合并它,而不是放弃自动合并。
  已存在同类 open PR 时复用它,不重复创建。

晋级 PR 不再被重复审查
  自动创建的 PR 带 PROMOTION_MARKER,其 pull_request 事件直接跳过。
  同一批提交在 push 时已经审过,重复审会在合并完成后凭空造出 Issue
  (此前确实产生了这样一个幽灵 Issue)。

合并即关闭该仓库全部审查 Issue
  两条触发路径:
  - 服务自己完成合并后立即关闭
  - 人在 Gitea 手动合并时,pull_request closed + merged=true 事件触发关闭
  手动合并是「代码已落地」最可靠的信号,不再依赖 push 侧的 merge 提交探测。

晋级使用 merge 而非 squash
  squash 会把晋级提交重写成全新提交,两个长期分支每晋级一次就多分叉
  一点,最终必然冲突(已在 offerpai_h5 上复现 add/add 冲突)。
  merge 保留共同祖先,下次晋级只携带新提交。

合并可等待性
  Gitea 异步计算 mergeable,原先只轮询 5 秒就放弃。改为指数退避约 30 秒,
  并区分「尚未算出」(继续等)与「确实冲突」(放弃)。失败时往 PR 留评论
  说明原因,不再只写服务日志。

验证(真实 webhook):
  推送 test → 审查通过 → 自动开 PR #9 (test→prd) → merge 合并
  → pull_request merged 事件 → 关闭 0 个待处理 Issue
  prd 顶端前进为合并提交,test/prd 保持一致,无幽灵 Issue 产生
2026-09-20 16:23:45 +08:00

249 lines
9.6 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 } 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 matches an explicit branch list, not globs", () => {
assert.ok(branchMatches("main", "main"));
assert.ok(branchMatches("refs/heads/main", "main"));
assert.ok(branchMatches("release/1.2", "main,release/1.2"));
assert.ok(branchMatches("dev", ""));
assert.ok(!branchMatches("feature/x", "main,release/1.2"));
assert.ok(!branchMatches("release/2.0", "release/*"), "globs are no longer patterns");
});
test("pullRequestMatches keys on the target branch, not the feature branch", () => {
// The two-stage flow: feature -> test -> prd.
assert.ok(pullRequestMatches("fix/x", "test", "test,prd"), "PR into test");
assert.ok(pullRequestMatches("test", "prd", "test,prd"), "promotion PR into prd");
assert.ok(!pullRequestMatches("fix/x", "master", "test,prd"), "PR into an unprotected branch");
assert.ok(pullRequestMatches("any", "any", ""), "empty list reviews everything");
// Listing a long-lived branch still reviews PRs originating from it.
assert.ok(pullRequestMatches("test", "unprotected", "test,prd"), "head branch listed");
});
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();
}
});