Files
git-codereview/app/lib/gitea.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

351 lines
12 KiB
JavaScript

/** Minimal Gitea REST client (no external dependencies). */
export class GiteaError extends Error {
constructor(message, { status, body, method, url } = {}) {
super(message);
this.name = "GiteaError";
this.status = status;
this.body = body;
this.method = method;
this.url = url;
}
}
/**
* Normalize a Gitea base URL to the server root.
* Accepts `http://host`, `http://host/`, or `http://host/api/v1` and always
* returns the root form, because every request path already carries the
* `/api/v1` prefix.
*/
export function normalizeBaseUrl(input) {
const trimmed = String(input || "").trim().replace(/\/+$/, "");
if (!trimmed) throw new Error("Gitea base URL is required");
return trimmed.replace(/\/api\/v1$/, "");
}
export class GiteaClient {
constructor({ baseUrl, token, timeoutMs = 60000, userAgent = "gitea-codereview" }) {
this.baseUrl = normalizeBaseUrl(baseUrl);
this.token = token;
this.timeoutMs = timeoutMs;
this.userAgent = userAgent;
}
async request(method, path, { body, query, raw = false, headers = {}, timeoutMs } = {}) {
const url = new URL(this.baseUrl + path);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
}
}
const init = {
method,
headers: {
Accept: raw ? "text/plain, application/json" : "application/json",
"User-Agent": this.userAgent,
...headers,
},
signal: AbortSignal.timeout(timeoutMs ?? this.timeoutMs),
};
if (this.token) init.headers.Authorization = `token ${this.token}`;
if (body !== undefined) {
init.headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(url, init);
} catch (err) {
throw new GiteaError(`Gitea request failed: ${method} ${url.pathname}: ${err.message}`, {
method, url: url.toString(),
});
}
const text = await res.text();
if (!res.ok) {
let parsed = null;
try { parsed = JSON.parse(text); } catch { /* keep raw text */ }
const detail = parsed?.message || text.slice(0, 400) || res.statusText;
throw new GiteaError(
`Gitea ${method} ${url.pathname} -> ${res.status}: ${detail}`,
{ status: res.status, body: parsed ?? text, method, url: url.toString() },
);
}
if (raw) return text;
if (!text) return null;
try { return JSON.parse(text); } catch { return text; }
}
get(path, options) { return this.request("GET", path, options); }
post(path, body, options) { return this.request("POST", path, { ...options, body }); }
patch(path, body, options) { return this.request("PATCH", path, { ...options, body }); }
put(path, body, options) { return this.request("PUT", path, { ...options, body }); }
del(path, options) { return this.request("DELETE", path, options); }
/** Verify the token and return the authenticated user. */
async getCurrentUser() {
return this.get("/api/v1/user");
}
async getVersion() {
return this.get("/api/v1/version");
}
async getRepo(owner, repo) {
return this.get(`/api/v1/repos/${owner}/${repo}`);
}
/** Repositories visible to the token, across pages. */
async listAccessibleRepos(limit = 50, maxPages = 40) {
const out = [];
const seen = new Set();
for (let page = 1; page <= maxPages; page += 1) {
let batch;
try {
batch = await this.get("/api/v1/user/repos", { query: { limit, page } });
} catch (err) {
if (page === 1) throw err;
break;
}
if (!Array.isArray(batch) || batch.length === 0) break;
for (const r of batch) {
if (r?.full_name && !seen.has(r.full_name)) {
seen.add(r.full_name);
out.push(r);
}
}
if (batch.length < limit) break;
}
return out;
}
async listRepoWebhooks(owner, repo) {
return this.get(`/api/v1/repos/${owner}/${repo}/hooks`);
}
/**
* Create (or update) the review webhook for a repository.
* Returns { created: boolean, id }.
*/
async ensureRepoWebhook(owner, repo, { url, secret, events = ["push", "pull_request"] }) {
const desired = {
type: "gitea",
active: true,
name: "gitea-codereview",
events,
config: { url, content_type: "json", ...(secret ? { secret } : {}) },
};
let existing = [];
try {
existing = (await this.listRepoWebhooks(owner, repo)) ?? [];
} catch {
existing = [];
}
const match = existing.find((h) => h.config?.url === url) ?? existing.find((h) => h.name === desired.name);
if (match) {
await this.patch(`/api/v1/repos/${owner}/${repo}/hooks/${match.id}`, desired);
return { created: false, id: match.id };
}
const created = await this.post(`/api/v1/repos/${owner}/${repo}/hooks`, desired);
return { created: true, id: created?.id ?? null };
}
/** Create a branch, optionally from another branch/tag/commit. */
async createBranch(owner, repo, { newBranch, fromBranch }) {
const payload = { new_branch_name: newBranch };
if (fromBranch) payload.old_ref_name = fromBranch;
return this.post(`/api/v1/repos/${owner}/${repo}/branches`, payload);
}
async listRepoBranches(owner, repo, limit = 100) {
const out = [];
for (let page = 1; page <= 20; page += 1) {
const batch = await this.get(`/api/v1/repos/${owner}/${repo}/branches`, {
query: { limit, page },
});
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
if (batch.length < limit) break;
}
return out;
}
async getBranch(owner, repo, branch) {
return this.get(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`);
}
async getIssue(owner, repo, index) {
return this.get(`/api/v1/repos/${owner}/${repo}/issues/${index}`);
}
async listIssues(owner, repo, query = {}) {
return this.get(`/api/v1/repos/${owner}/${repo}/issues`, { query });
}
async createIssue(owner, repo, { title, body, labels, assignees }) {
const payload = { title, body };
if (labels?.length) payload.labels = labels;
if (assignees?.length) payload.assignees = assignees;
return this.post(`/api/v1/repos/${owner}/${repo}/issues`, payload);
}
async updateIssue(owner, repo, index, patch) {
return this.patch(`/api/v1/repos/${owner}/${repo}/issues/${index}`, patch);
}
async listIssueComments(owner, repo, index, limit = 100) {
const out = [];
for (let page = 1; page <= 20; page += 1) {
const batch = await this.get(`/api/v1/repos/${owner}/${repo}/issues/${index}/comments`, {
query: { limit, page },
});
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
if (batch.length < limit) break;
}
return out;
}
async createIssueComment(owner, repo, index, body) {
return this.post(`/api/v1/repos/${owner}/${repo}/issues/${index}/comments`, { body });
}
async updateIssueComment(owner, repo, commentId, body) {
return this.patch(`/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });
}
async createCommitStatus(owner, repo, sha, { state, context, description, targetUrl }) {
return this.post(`/api/v1/repos/${owner}/${repo}/statuses/${sha}`, {
state,
context,
description: description?.slice(0, 255) ?? "",
target_url: targetUrl ?? "",
});
}
async getCommitStatuses(owner, repo, ref) {
return this.get(`/api/v1/repos/${owner}/${repo}/commits/${ref}/statuses`);
}
async getPullRequest(owner, repo, index) {
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 });
}
async listPullRequestFiles(owner, repo, index) {
return this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}/files`);
}
async listPullRequestCommits(owner, repo, index) {
return this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}/commits`);
}
/**
* Create a review with optional inline comments.
* `comments` entries use { path, body, newPosition, oldPosition }.
*/
async createPullReview(owner, repo, index, { event = "COMMENT", body = "", commitId, comments = [] }) {
const payload = { event, body };
if (commitId) payload.commit_id = commitId;
if (comments.length) {
payload.comments = comments.map((c) => {
const entry = { path: c.path, body: c.body };
if (c.newPosition) entry.new_position = c.newPosition;
else if (c.oldPosition) entry.old_position = c.oldPosition;
return entry;
});
}
return this.post(`/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, payload);
}
async listPullReviews(owner, repo, index, limit = 50) {
const out = [];
for (let page = 1; page <= 20; page += 1) {
const batch = await this.get(`/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, {
query: { limit, page },
});
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
if (batch.length < limit) break;
}
return out;
}
async mergePullRequest(owner, repo, index, { style = "squash", title, message, deleteBranch, headCommitId, mergeWhenChecksSucceed = false } = {}) {
const payload = { do: style };
if (title) payload.merge_title_field = title;
if (message) payload.merge_message_field = message;
if (deleteBranch !== undefined) payload.delete_branch_after_merge = Boolean(deleteBranch);
if (headCommitId) payload.head_commit_id = headCommitId;
if (mergeWhenChecksSucceed) payload.merge_when_checks_succeed = true;
return this.post(`/api/v1/repos/${owner}/${repo}/pulls/${index}/merge`, payload);
}
async listRepoLabels(owner, repo) {
return this.get(`/api/v1/repos/${owner}/${repo}/labels`, { query: { limit: 100 } });
}
async createRepoLabel(owner, repo, { name, color = "#1f6feb", description = "" }) {
return this.post(`/api/v1/repos/${owner}/${repo}/labels`, { name, color, description });
}
/**
* Resolve label names to repository label IDs, creating missing labels.
* Gitea's issue API takes label IDs, not names.
* @returns {Promise<number[]>} label IDs that could be resolved.
*/
async ensureLabels(owner, repo, names) {
if (!names?.length) return [];
let existing = [];
try {
existing = (await this.listRepoLabels(owner, repo)) ?? [];
} catch {
existing = [];
}
const byName = new Map(existing.map((l) => [l.name, l.id]));
for (const name of names) {
if (byName.has(name)) continue;
try {
const created = await this.createRepoLabel(owner, repo, { name });
if (created?.id) byName.set(name, created.id);
} catch {
// Label creation is best-effort; issue creation still proceeds.
}
}
return names.map((n) => byName.get(n)).filter((id) => Number.isInteger(id));
}
async getUser(login) {
return this.get(`/api/v1/users/${encodeURIComponent(login)}`);
}
}
/** Derive the repository web URL from an API base URL. */
export function webBaseUrl(apiBaseUrl) {
return normalizeBaseUrl(apiBaseUrl);
}