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 产生
This commit is contained in:
@@ -232,6 +232,27 @@ export class GiteaClient {
|
||||
return this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}`);
|
||||
}
|
||||
|
||||
/** Open a pull request. */
|
||||
async createPullRequest(owner, repo, { title, head, base, body = "" }) {
|
||||
return this.post(`/api/v1/repos/${owner}/${repo}/pulls`, { title, head, base, body });
|
||||
}
|
||||
|
||||
/** Close a pull request without merging it. */
|
||||
async closePullRequest(owner, repo, index) {
|
||||
return this.patch(`/api/v1/repos/${owner}/${repo}/pulls/${index}`, { state: "closed" });
|
||||
}
|
||||
|
||||
/** Compare two refs; returns the file list, or null when they are identical. */
|
||||
async compareRefs(owner, repo, base, head) {
|
||||
try {
|
||||
return await this.get(`/api/v1/repos/${owner}/${repo}/compare/${base}...${head}`);
|
||||
} catch (err) {
|
||||
// Gitea answers 404 when the two refs point at the same commit.
|
||||
if (err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async listPullRequests(owner, repo, query = {}) {
|
||||
return this.get(`/api/v1/repos/${owner}/${repo}/pulls`, { query });
|
||||
}
|
||||
|
||||
@@ -183,6 +183,14 @@ export class OcrRunner {
|
||||
return res.stdout.trim().split("\n")[0];
|
||||
}
|
||||
|
||||
/** Parent commit count for a commit; >1 means it is a merge commit. */
|
||||
async parentCount(dir, sha) {
|
||||
const res = await git(["rev-list", "--parents", "-n", "1", sha], { cwd: dir, timeoutMs: 60000 });
|
||||
if (res.code !== 0) return 0;
|
||||
const parts = res.stdout.trim().split(/\s+/).filter(Boolean);
|
||||
return Math.max(0, parts.length - 1);
|
||||
}
|
||||
|
||||
async changedFiles(dir, fromSha, toSha) {
|
||||
const res = await git(
|
||||
["diff", "--name-only", "--diff-filter=ACMRTUXB", fromSha, toSha],
|
||||
|
||||
+199
-3
@@ -14,6 +14,9 @@ import {
|
||||
|
||||
export const SUMMARY_MARKER = "<!-- gitea-codereview:summary -->";
|
||||
export const COMMENT_MARKER = "<!-- gitea-codereview -->";
|
||||
// Marks a pull request this service opened itself to promote a branch. Its
|
||||
// webhook event is ignored because the same commits were already reviewed.
|
||||
export const PROMOTION_MARKER = "<!-- gitea-codereview:promotion -->";
|
||||
export const STATUS_CONTEXT = "code-review/ocr";
|
||||
|
||||
export class SkipJob extends Error {
|
||||
@@ -645,14 +648,20 @@ export class ReviewEngine {
|
||||
return created?.number ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the change into the next branch up the chain.
|
||||
*
|
||||
* When the reviewed ref has an open pull request we merge it. When it does
|
||||
* not (a direct push to a checked branch) we open one automatically, so a
|
||||
* push to `test` still flows into `prd` with a reviewable record.
|
||||
*/
|
||||
async maybeAutoMerge({
|
||||
client, repo, job, prNumber, blocking, toSha, appendLog,
|
||||
reviewIncomplete = false, statusState = "success",
|
||||
}) {
|
||||
if (!repo.auto_merge) return false;
|
||||
if (!prNumber) {
|
||||
appendLog("auto-merge skipped: no pull request for this branch");
|
||||
return false;
|
||||
return this.promoteBranch({ client, repo, job, toSha, appendLog, reviewIncomplete, statusState });
|
||||
}
|
||||
// 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
|
||||
@@ -706,7 +715,8 @@ export class ReviewEngine {
|
||||
headCommitId: toSha,
|
||||
mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed",
|
||||
});
|
||||
appendLog("auto-merge requested");
|
||||
appendLog(`auto-merge requested for PR #${prNumber}`);
|
||||
await this.closeRepoIssues({ client, repo, appendLog, reason: `PR #${prNumber} 已合并` });
|
||||
return true;
|
||||
} catch (err) {
|
||||
appendLog(`auto-merge failed: ${err.message}`);
|
||||
@@ -714,6 +724,192 @@ export class ReviewEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a pull request from the pushed branch into the next branch in the
|
||||
* chain and merge it, then close the repository's review issues.
|
||||
*/
|
||||
async promoteBranch({ client, repo, job, toSha, appendLog, reviewIncomplete, statusState }) {
|
||||
if (reviewIncomplete) {
|
||||
appendLog("auto-merge skipped: review coverage was incomplete");
|
||||
return false;
|
||||
}
|
||||
if (statusState !== "success") {
|
||||
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`);
|
||||
return false;
|
||||
}
|
||||
const target = managed;
|
||||
if (!branchMatches(target, repo.check_branches) && target !== managed) {
|
||||
appendLog(`auto-merge skipped: ${target} is not a checked branch`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let head;
|
||||
let base;
|
||||
try {
|
||||
head = await client.getBranch(repo.owner, repo.name, job.ref_name);
|
||||
base = await client.getBranch(repo.owner, repo.name, target);
|
||||
} catch (err) {
|
||||
appendLog(`auto-merge skipped: cannot resolve branches: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
const headSha = head?.commit?.id;
|
||||
const baseSha = base?.commit?.id;
|
||||
if (!headSha || !baseSha) {
|
||||
appendLog("auto-merge skipped: branch head unknown");
|
||||
return false;
|
||||
}
|
||||
if (headSha === baseSha) {
|
||||
appendLog(`auto-merge skipped: ${job.ref_name} already matches ${target}`);
|
||||
return false;
|
||||
}
|
||||
// Only promote the exact commit that was reviewed.
|
||||
if (headSha !== toSha) {
|
||||
appendLog(`auto-merge skipped: ${job.ref_name} moved to ${headSha.slice(0, 10)} after the review`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Is there already an open PR for this promotion? Reuse it instead of
|
||||
// opening a duplicate on every push.
|
||||
let promotionPr = null;
|
||||
try {
|
||||
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", limit: 50 });
|
||||
promotionPr = (open || []).find(
|
||||
(p) => p.head?.ref === job.ref_name && p.base?.ref === target,
|
||||
) ?? null;
|
||||
} catch { /* fall through to creating one */ }
|
||||
|
||||
if (!promotionPr) {
|
||||
try {
|
||||
promotionPr = await client.createPullRequest(repo.owner, repo.name, {
|
||||
title: `[OCR] ${job.ref_name} → ${target}`,
|
||||
head: job.ref_name,
|
||||
base: target,
|
||||
body: [
|
||||
PROMOTION_MARKER,
|
||||
`由 gitea-codereview 自动创建。`,
|
||||
"",
|
||||
`- 来源分支:\`${job.ref_name}\``,
|
||||
`- 目标分支:\`${target}\``,
|
||||
`- 审查提交:\`${toSha}\``,
|
||||
`- 代码审查已通过(job #${job.id})`,
|
||||
].join("\n"),
|
||||
});
|
||||
appendLog(`opened promotion PR #${promotionPr?.number} (${job.ref_name} -> ${target})`);
|
||||
} catch (err) {
|
||||
appendLog(`auto-merge failed: cannot open promotion PR: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
appendLog(`reusing open promotion PR #${promotionPr.number}`);
|
||||
}
|
||||
|
||||
const index = promotionPr?.number;
|
||||
if (!index) {
|
||||
appendLog("auto-merge failed: promotion PR has no number");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Gitea computes mergeability asynchronously and a large divergence can
|
||||
// take a while, so poll with backoff instead of giving up after a few
|
||||
// seconds. `null`/`undefined` means "not computed yet"; only an explicit
|
||||
// false is a real conflict.
|
||||
let pr = promotionPr;
|
||||
let delayMs = 1000;
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
if (pr.mergeable === true || pr.mergeable === false) break;
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
delayMs = Math.min(delayMs * 1.6, 8000);
|
||||
pr = await client.getPullRequest(repo.owner, repo.name, index).catch(() => pr);
|
||||
}
|
||||
if (pr.mergeable === false) {
|
||||
appendLog(`auto-merge skipped: promotion PR #${index} conflicts with ${target}`);
|
||||
await client.createIssueComment(repo.owner, repo.name, index,
|
||||
`自动合并失败:\`${job.ref_name}\` 与 \`${target}\` 存在冲突,需要人工解决后重新推送。\n\n` +
|
||||
`审查提交:\`${toSha}\``).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
if (pr.mergeable === undefined || pr.mergeable === null) {
|
||||
appendLog(`auto-merge skipped: promotion PR #${index} mergeability still unknown after polling`);
|
||||
await client.createIssueComment(repo.owner, repo.name, index,
|
||||
`自动合并暂缓:Gitea 尚未算出该 PR 是否可合并。稍后重新推送或在界面手动合并。\n\n` +
|
||||
`审查提交:\`${toSha}\``).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
if (pr.head?.sha && pr.head.sha !== toSha) {
|
||||
appendLog(`auto-merge skipped: promotion PR #${index} head moved`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Promotion always merges (never squashes): squashing rewrites the
|
||||
// promoted commits into a brand-new commit, so the two long-lived
|
||||
// branches diverge a little more on every promotion and eventually
|
||||
// conflict. A real merge keeps the shared ancestry, so the next
|
||||
// promotion only carries the new commits.
|
||||
await client.mergePullRequest(repo.owner, repo.name, index, {
|
||||
style: "merge",
|
||||
title: pr.title,
|
||||
deleteBranch: false,
|
||||
headCommitId: toSha,
|
||||
mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed",
|
||||
});
|
||||
appendLog(`auto-merge requested for promotion PR #${index}`);
|
||||
await this.closeRepoIssues({
|
||||
client, repo, appendLog,
|
||||
reason: `${job.ref_name} 已合并到 ${target}(PR #${index})`,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
appendLog(`auto-merge failed: promotion PR #${index}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every open review issue for this repository.
|
||||
*
|
||||
* Called whenever a merge lands: once code is on its way into the managed
|
||||
* branch the previous round is over, and any remaining problem will be
|
||||
* reported again by the next review.
|
||||
*/
|
||||
async closeRepoIssues({ client, repo, appendLog, reason }) {
|
||||
let open = [];
|
||||
try {
|
||||
open = await client.get(`/api/v1/repos/${repo.owner}/${repo.name}/issues`, {
|
||||
query: { state: "open", type: "issues", limit: 100 },
|
||||
}) ?? [];
|
||||
} catch (err) {
|
||||
appendLog(`cannot list issues to close: ${err.message}`);
|
||||
return 0;
|
||||
}
|
||||
const mine = open.filter((i) => String(i.body || "").includes(SUMMARY_MARKER));
|
||||
let closed = 0;
|
||||
for (const issue of mine) {
|
||||
try {
|
||||
await client.createIssueComment(repo.owner, repo.name, issue.number,
|
||||
`已合并:${reason}。本轮问题视为结束,关闭该 Issue;如后续审查再发现问题会重新创建。`);
|
||||
await client.updateIssue(repo.owner, repo.name, issue.number, { state: "closed" });
|
||||
closed += 1;
|
||||
} catch (err) {
|
||||
appendLog(`cannot close issue #${issue.number}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (closed) appendLog(`closed ${closed} review issue(s) after merge`);
|
||||
return closed;
|
||||
}
|
||||
|
||||
async failJob(job, err) {
|
||||
const message = err instanceof SkipJob
|
||||
? `skipped: ${err.reason}`
|
||||
|
||||
+59
-4
@@ -15,7 +15,10 @@ import {
|
||||
import { GiteaClient } from "./lib/gitea.js";
|
||||
import { OcrRunner } from "./lib/ocr.js";
|
||||
import { JobQueue } from "./lib/queue.js";
|
||||
import { ReviewEngine, SkipJob, branchMatches, parseRepoUrl, pullRequestMatches } from "./lib/review.js";
|
||||
import {
|
||||
ReviewEngine, SkipJob, branchMatches, parseRepoUrl, pullRequestMatches,
|
||||
PROMOTION_MARKER,
|
||||
} from "./lib/review.js";
|
||||
|
||||
const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||
const ROOT_DIR = resolve(APP_DIR, "..");
|
||||
@@ -189,6 +192,33 @@ async function handlePush(payload, cfg) {
|
||||
const toSha = payload.after || payload.head_commit?.id;
|
||||
if (!toSha) return { queued: 0, reason: "no head commit in payload" };
|
||||
|
||||
// A merge that a human performed in Gitea also ends the current round: the
|
||||
// code has landed, so the open review issues are stale.
|
||||
if (refName === (repo.managed_branch || repo.base_branch) && payload.commits?.length) {
|
||||
const workspace = join(CONFIG.dataDir, "workspaces", `${repo.owner}__${repo.name}`);
|
||||
if (existsSync(join(workspace, ".git"))) {
|
||||
try {
|
||||
const runner = new OcrRunner({ command: cfg.ocrCommand });
|
||||
const parents = await runner.parentCount(workspace, toSha);
|
||||
if (parents > 1) {
|
||||
const client = new GiteaClient({
|
||||
baseUrl: cfg.giteaUrl,
|
||||
token: repo.gitea_token || cfg.giteaToken,
|
||||
});
|
||||
const engine = new ReviewEngine({ db, config: cfg, logger });
|
||||
const closed = await engine.closeRepoIssues({
|
||||
client, repo, appendLog: () => {},
|
||||
reason: `管理分支 ${refName} 收到合并提交 ${toSha.slice(0, 10)}`,
|
||||
});
|
||||
if (closed) logger.info(`closed ${closed} issue(s) after merge into ${refName}`);
|
||||
return { queued: 0, reason: `merge detected on ${refName}; closed ${closed} issue(s)` };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`merge detection failed on ${owner}/${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scoped = repo.review_scope === "pr";
|
||||
const pr = scoped ? null : await findOpenPullRequestForRef(cfg, repo, refName, toSha);
|
||||
if (scoped && !pr) {
|
||||
@@ -224,19 +254,44 @@ const PR_ACTIONS = new Set([
|
||||
|
||||
async function handlePullRequest(payload, cfg) {
|
||||
const action = payload.action;
|
||||
if (!PR_ACTIONS.has(action)) {
|
||||
return { queued: 0, reason: `action ${action} ignored` };
|
||||
}
|
||||
const owner = payload.repository?.owner?.username || payload.repository?.owner?.login;
|
||||
const name = payload.repository?.name;
|
||||
const repo = owner && name ? findRepository(db, owner, name) : null;
|
||||
if (!repo || !repo.enabled) return { queued: 0, reason: "repository not configured" };
|
||||
|
||||
// A merged pull request is the authoritative "code has landed" signal: the
|
||||
// open review issues describe a state that no longer exists.
|
||||
if (action === "closed" && payload.pull_request?.merged) {
|
||||
const pr = payload.pull_request;
|
||||
const client = new GiteaClient({
|
||||
baseUrl: cfg.giteaUrl,
|
||||
token: repo.gitea_token || cfg.giteaToken,
|
||||
});
|
||||
const engine = new ReviewEngine({ db, config: cfg, logger });
|
||||
const closed = await engine.closeRepoIssues({
|
||||
client, repo, appendLog: () => {},
|
||||
reason: `PR #${pr.number} 已合并到 ${pr.base?.ref ?? "目标分支"}`,
|
||||
});
|
||||
logger.info(`PR #${pr.number} merged into ${pr.base?.ref}; closed ${closed} issue(s)`);
|
||||
return { queued: 0, closedIssues: closed, reason: "pull request merged" };
|
||||
}
|
||||
|
||||
if (!PR_ACTIONS.has(action)) {
|
||||
return { queued: 0, reason: `action ${action} ignored` };
|
||||
}
|
||||
if (repo.review_scope === "push") {
|
||||
return { queued: 0, reason: "review_scope=push; PRs reviewed via push events" };
|
||||
}
|
||||
const pr = payload.pull_request;
|
||||
if (!pr) return { queued: 0, reason: "no pull_request in payload" };
|
||||
if (pr.draft) return { queued: 0, reason: "draft pull request" };
|
||||
|
||||
// A promotion pull request this service opened itself: the same commits were
|
||||
// already reviewed when they were pushed, so reviewing again would produce a
|
||||
// duplicate issue for code that has already been merged.
|
||||
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)) {
|
||||
return {
|
||||
queued: 0,
|
||||
|
||||
Reference in New Issue
Block a user