1. 所有问题都建 Issue(原先只有阻断级才建) 建 Issue 的判据从 blocking 改为 findings:任何等级的问题都创建/更新 Issue, 等级体现在标题 [OCR][medium] 与标签 medium 上,阻断项在正文标注 「(阻断合并)」。低等级问题不再丢失,也不会挡住合并。 无任何发现时才关闭该分支的 Issue。 2. 合并失败自动重试 Gitea 在算完 PR 可合并性之前会返回 405 Please try again later, 原先直接放弃,晋级随机失败。现在对 405/409/5xx 按指数退避重试 4 次; 权限不足、真实冲突等永久失败立即放弃并往 PR 留言说明。 PR 已被合并(405 already merged)视为成功,幂等收尾。 3. push 与 PR 事件分流(本次新发现的 bug) 合并路径原先按「是否找到关联 PR」判断,于是一个残留的 test→prd PR 会让后续 push 被当成 PR 事件,走错分支并跳过晋级,日志还会给出 「PR targets prd, which is not a checked branch」这种与实际不符的原因。 现在按事件类型决定:trigger 以 pull_request 开头才走合并 PR 路径, push/manual 一律走晋级分支。关联 PR 仅用于评论归属。 验证: - 直接 push test → 建出 issue #4([OCR][medium],标签 code-review,medium) - 合并重试与幂等分支的判定表全部通过 - 事件分流判定表:push/manual → promote,pull_request.* → merge PR
389 lines
13 KiB
JavaScript
389 lines
13 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)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gitea answers 405 "Please try again later" while it is still computing a pull
|
|
* request's mergeability, and 409 for transient lock contention. Both clear up
|
|
* on their own, so a merge is retried on those before giving up.
|
|
*/
|
|
export function isTransientMergeError(err) {
|
|
if (!err) return false;
|
|
const msg = String(err.message || "");
|
|
// Already merged is a final state, not something to retry.
|
|
if (/already merged/i.test(msg)) return false;
|
|
if (err.status === 405 || err.status === 409) return true;
|
|
if (err.status === 500 || err.status === 502 || err.status === 503 || err.status === 504) return true;
|
|
return /try again later|please try again|mergeability|not ready/i.test(msg);
|
|
}
|
|
|
|
/** True when a merge failed only because the pull request is already merged. */
|
|
export function isAlreadyMerged(err) {
|
|
return /already merged/i.test(String(err?.message || ""));
|
|
}
|
|
|
|
/** Retry `fn` while it throws a transient error, with exponential backoff. */
|
|
export async function retryTransient(fn, { attempts = 4, baseDelayMs = 1500, onRetry } = {}) {
|
|
let delay = baseDelayMs;
|
|
let lastErr;
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
lastErr = err;
|
|
if (!isTransientMergeError(err) || attempt === attempts) throw err;
|
|
onRetry?.(attempt, err);
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
delay = Math.min(delay * 2, 12000);
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
/** Derive the repository web URL from an API base URL. */
|
|
export function webBaseUrl(apiBaseUrl) {
|
|
return normalizeBaseUrl(apiBaseUrl);
|
|
} |