feat: Gitea 自动代码审查服务
基于 OpenCodeReview 的 webhook 服务:监听 Gitea 的 push 与 Pull Request 事件, 调用 OCR 审查 diff,把结果发布回 Gitea,并按阻断阈值决定是否自动合并。 主要能力: - Push / PR 事件触发,支持分支 glob 过滤与 PR-only / push-only 范围 - PR 内联评论(按 diff 行号定位)、汇总评论、Issue 生命周期、提交状态 - 可配置阻断阈值(严重级别 / 类别 / 任意意见) - 无阻断问题时自动合并,审查覆盖不完整时拒绝合并 - 内置 Web 后台:仓库配置、任务日志、失败重跑、连通性自检 - SQLite 持久化,worker 重启回收卡死任务,失败自动重试 实现为独立服务而非 Gitea Action:本机 act_runner 指向的实例不可达, 且后台配置与任务历史需要独立进程承载。
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Gitea connection
|
||||
CR_GITEA_URL=http://192.168.31.51
|
||||
# Admin token used to publish reviews, issues and statuses. Create one in
|
||||
# Gitea: Settings -> Applications -> Generate New Token (scopes: repo, issue).
|
||||
CR_GITEA_TOKEN=
|
||||
|
||||
# Shared secret configured on the Gitea webhook (Settings -> Webhooks).
|
||||
# When set, the service rejects any webhook whose HMAC signature does not match.
|
||||
CR_WEBHOOK_SECRET=
|
||||
|
||||
# Bearer token protecting the admin UI and REST API. Leave empty to disable auth
|
||||
# (only acceptable when the port is not exposed beyond a trusted network).
|
||||
CR_ADMIN_TOKEN=
|
||||
|
||||
# LLM endpoint used by OpenCodeReview.
|
||||
CR_LLM_URL=
|
||||
CR_LLM_TOKEN=
|
||||
CR_LLM_MODEL=
|
||||
CR_LLM_PROTOCOL=openai
|
||||
CR_LLM_AUTH_HEADER=
|
||||
CR_LLM_EXTRA_HEADERS=
|
||||
CR_LLM_TIMEOUT=180
|
||||
|
||||
# Optional: path to a custom OCR rule JSON inside the container.
|
||||
CR_RULE_PATH=
|
||||
|
||||
# Host port for the admin UI.
|
||||
CR_HOST_PORT=8090
|
||||
|
||||
# Runtime tuning (optional).
|
||||
CR_CONCURRENCY=4
|
||||
CR_REVIEW_TIMEOUT_MS=2700000
|
||||
CR_MAX_TOKENS_BUDGET=0
|
||||
@@ -0,0 +1,3 @@
|
||||
# Keep line endings stable: the app runs in a Linux container.
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
data/
|
||||
node_modules/
|
||||
*.log
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Gitea Code Review service.
|
||||
#
|
||||
# OCR is invoked as a subprocess from the app container, so the image ships
|
||||
# Node.js, git (>= 2.41, required by OpenCodeReview) and the ocr CLI itself.
|
||||
FROM node:22-trixie-slim
|
||||
|
||||
ARG OCR_VERSION=1.12.7
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_ENV=production \
|
||||
CR_DATA_DIR=/data \
|
||||
CR_HOST=0.0.0.0 \
|
||||
CR_PORT=8090
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates tini \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& git --version \
|
||||
&& npm install -g "@alibaba-group/open-code-review@${OCR_VERSION}" \
|
||||
&& ocr version
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY app ./app
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["node", "app/server.js"]
|
||||
@@ -0,0 +1,219 @@
|
||||
# Gitea 自动代码审查
|
||||
|
||||
基于 [OpenCodeReview](https://github.com/alibaba/open-code-review)(OCR)的 Gitea 自动代码审查服务。
|
||||
监听仓库推送和 Pull Request,调用 OCR 分析 diff,把结果发布回 Gitea,并按规则决定是否自动合并。
|
||||
|
||||
## 它做什么
|
||||
|
||||
```text
|
||||
Push / Pull Request 事件
|
||||
↓ Gitea Webhook(HMAC 签名)
|
||||
gitea-codereview
|
||||
↓ git fetch + OCR 审查
|
||||
OpenCodeReview CLI → LLM
|
||||
↓ 结构化 JSON(文件 / 行号 / 类别 / 严重级别)
|
||||
Gitea:内联评论 + 汇总评论 + Issue + 提交状态
|
||||
↓ 无阻断问题且开启自动合并
|
||||
自动合并 / 等待检查通过后合并
|
||||
```
|
||||
|
||||
## 功能
|
||||
|
||||
**审查触发**
|
||||
- 监听分支推送,自动对比上次提交或与目标分支的 merge-base
|
||||
- 监听 Pull Request 的 opened / synchronize / reopened / ready_for_review
|
||||
- 按分支 glob 过滤(`*`、`release/*`、`main`),可选只审 PR 或只审 push
|
||||
- 同一 commit 在队列中只入队一次;webhook 按 delivery id 去重
|
||||
|
||||
**发布结果**
|
||||
- PR 内联评论,带 `[category · severity]` 标记和 `suggestion` 代码块
|
||||
- 每条内联评论都校验是否落在本次 diff 的行上,落不到的汇总进审查总结
|
||||
- 提交状态 `code-review/ocr`(success / failure / error),可配成必需检查
|
||||
- 同一仓库同一分支只维护一个 Issue:有问题时创建或更新,修好后自动关闭
|
||||
- 可配置阻断阈值:按严重级别、按类别,或任何意见都算
|
||||
|
||||
**自动合并**
|
||||
- 仅在无阻断问题、审查覆盖完整、提交状态为 success 时才触发
|
||||
- 两种模式:`when_checks_succeed`(等分支保护检查通过)和 `immediate`
|
||||
- 合并方式、是否删除分支可配
|
||||
- PR head 已变化或 PR 不可合并时自动放弃
|
||||
|
||||
**运维**
|
||||
- 内置 Web 后台:仓库配置、任务列表、实时日志、重跑、连通性自检
|
||||
- 每次审查的完整日志和结果 JSON 落库
|
||||
- worker 重启后自动回收卡住的任务,失败任务自动重试一次
|
||||
- 全局或按仓库覆盖 Gitea token、LLM 端点 / 模型 / Key、排除路径、并发数
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
gitea-codereview/
|
||||
├── app/
|
||||
│ ├── server.js HTTP 服务:webhook、REST API、静态后台
|
||||
│ ├── lib/
|
||||
│ │ ├── db.js SQLite 结构与查询
|
||||
│ │ ├── gitea.js Gitea REST 客户端
|
||||
│ │ ├── ocr.js 调用 OCR CLI,解析 JSON
|
||||
│ │ ├── diff.js 解析 unified diff,定位内联评论锚点
|
||||
│ │ ├── review.js 审查流水线:发布、Issue、提交状态、自动合并
|
||||
│ │ └── queue.js 串行任务队列
|
||||
│ └── static/ 后台前端
|
||||
├── tests/core.test.js
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
### 1. 准备 Gitea
|
||||
|
||||
**创建访问 Token**:Gitea → 用户设置 → 应用 → 生成令牌,勾选 `repo` 与 `issue` 权限。
|
||||
|
||||
**允许 Gitea 向内网投递 Webhook**(Gitea 默认拦截内网地址)。在 Gitea 的 `app.ini` 或容器环境变量中加:
|
||||
|
||||
```yaml
|
||||
GITEA__webhook__ALLOWED_HOST_LIST: "192.168.31.51"
|
||||
```
|
||||
|
||||
多个地址用逗号分隔,也可写 CIDR(`192.168.31.0/24`)或内置名(`private`、`loopback`)。
|
||||
|
||||
### 2. 准备 LLM 端点
|
||||
|
||||
OCR 需要一个 OpenAI 兼容或 Anthropic 兼容的接口,填进 `.env` 的 `CR_LLM_URL` / `CR_LLM_TOKEN` / `CR_LLM_MODEL`。
|
||||
用 `CR_LLM_PROTOCOL=openai` 或 `anthropic` 指定协议。
|
||||
|
||||
### 3. 配置并启动
|
||||
|
||||
```bash
|
||||
cd gitea-codereview
|
||||
cp .env.example .env
|
||||
# 填入 CR_GITEA_TOKEN、CR_WEBHOOK_SECRET、CR_ADMIN_TOKEN、CR_LLM_*
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
curl -fsS http://localhost:8090/api/health
|
||||
```
|
||||
|
||||
打开后台 `http://<服务器>:8090`:
|
||||
|
||||
0. 如果设置了 `CR_ADMIN_TOKEN`,页面会先要求输入该 Token(保存在浏览器本地,可随时「清除访问 Token」)
|
||||
1. 在「设置」里确认 Gitea 地址、LLM 端点,点「测试 Gitea」和「测试 LLM」验证连通
|
||||
2. 在「仓库」里新增要审查的仓库,配置分支过滤、阻断阈值、是否自动合并
|
||||
3. 在 Gitea 仓库 Settings → Webhooks 添加 Webhook:
|
||||
- 目标 URL:`http://<服务器>:8090/webhook/gitea`
|
||||
- 内容类型:`application/json`
|
||||
- 密钥:填 `.env` 里的 `CR_WEBHOOK_SECRET`
|
||||
- 事件:Push 与 Pull Request
|
||||
|
||||
也可以只配 `.env` 不打开后台;后台的修改会持久化到 SQLite 卷。
|
||||
|
||||
## 配置项
|
||||
|
||||
### 全局(`.env` 或后台「设置」)
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `CR_GITEA_URL` | Gitea 根地址,如 `http://192.168.31.51` |
|
||||
| `CR_GITEA_TOKEN` | 发布评论、Issue、提交状态用的 Token |
|
||||
| `CR_WEBHOOK_SECRET` | Webhook HMAC 密钥;设置后拒绝签名不符的请求 |
|
||||
| `CR_ADMIN_TOKEN` | 保护后台和 REST API 的 Bearer Token;设置后打开后台需先输入;留空则不校验 |
|
||||
| `CR_LLM_URL` / `CR_LLM_TOKEN` / `CR_LLM_MODEL` | LLM 端点与凭据 |
|
||||
| `CR_LLM_PROTOCOL` | `openai` 或 `anthropic` |
|
||||
| `CR_LLM_AUTH_HEADER` | 自定义认证头名,默认由协议决定(如 `x-api-key`) |
|
||||
| `CR_LLM_EXTRA_HEADERS` | 附加请求头,`K=V,K=V` |
|
||||
| `CR_LLM_TIMEOUT` | 单次 LLM 请求超时秒数,默认 `180` |
|
||||
| `CR_RULE_PATH` | 自定义 OCR 规则 JSON 的容器内路径 |
|
||||
| `CR_HOST_PORT` | 后台映射到宿主机的端口,默认 `8090` |
|
||||
| `CR_CONCURRENCY` | 单个审查任务的并发文件数 |
|
||||
| `CR_REVIEW_TIMEOUT_MS` | 单次审查超时,默认 45 分钟 |
|
||||
| `CR_MAX_TOKENS_BUDGET` | 单次审查 token 上限,`0` 为不限 |
|
||||
| `CR_OCR_COMMAND` | OCR 可执行文件名,默认 `ocr` |
|
||||
| `CR_DATA_DIR` / `CR_DB_PATH` | 数据目录与 SQLite 路径,默认容器内 `/data` |
|
||||
| `CR_POLL_MS` / `CR_MAX_ATTEMPTS` | 队列轮询间隔与失败重试次数 |
|
||||
|
||||
### 按仓库(后台「仓库」)
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `branch_patterns` | 监听的分支 glob,逗号分隔;`*` 为全部 |
|
||||
| `review_scope` | `both` / `pr` / `push` |
|
||||
| `base_branch` | 对比基准分支,也是无 PR 时 push 审查的目标 |
|
||||
| `block_severity` | 阻断级别阈值,如 `critical,high`;留空则不看级别 |
|
||||
| `block_categories` | 额外按类别阻断,如 `security` |
|
||||
| `fail_on_findings` | 打开后任何意见都视为阻断 |
|
||||
| `auto_merge` | 无阻断问题时自动合并 |
|
||||
| `auto_merge_mode` | `when_checks_succeed` / `immediate` |
|
||||
| `merge_method` | `squash` / `merge` / `rebase` / `rebase-merge` / `fast-forward-only` |
|
||||
| `delete_branch` | 合并后删除源分支 |
|
||||
| `publish_mode` | `inline`(PR 内联评论)/ `issue-only` |
|
||||
| `create_issue` | 是否创建 / 更新 Issue |
|
||||
| `issue_labels` | Issue 标签,不存在时自动创建 |
|
||||
| `excludes` | OCR 排除路径,gitignore 风格,逗号分隔 |
|
||||
| `max_comments` | 单次最多发布多少条意见 |
|
||||
| `gitea_token` / `llm_token` / `llm_model` | 覆盖全局配置 |
|
||||
|
||||
## REST API
|
||||
|
||||
所有 `/api/*` 接口在设置 `CR_ADMIN_TOKEN` 后需要 `Authorization: Bearer <token>`。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | 健康检查与队列统计(免鉴权) |
|
||||
| `GET` `PUT` | `/api/settings` | 读写全局设置 |
|
||||
| `GET` `POST` | `/api/repos` | 列出 / 新增仓库配置 |
|
||||
| `GET` `PATCH` `DELETE` | `/api/repos/:id` | 读取 / 修改 / 删除 |
|
||||
| `POST` | `/api/repos/:id/discover` | 校验连接,返回默认分支与分支列表 |
|
||||
| `GET` | `/api/jobs` | 任务列表(`?repo_id=`、`?limit=`) |
|
||||
| `GET` | `/api/jobs/:id` | 任务详情,含日志 |
|
||||
| `POST` | `/api/jobs/:id/retry` | 重跑任务 |
|
||||
| `POST` | `/api/review` | 手动排队一次审查 |
|
||||
| `POST` | `/api/gitea/test` | 测试 Gitea 连接 |
|
||||
| `POST` | `/api/selftest` | 测试 OCR 与 LLM 连通性 |
|
||||
|
||||
手动触发一次审查:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8090/api/review \
|
||||
-H "Authorization: Bearer $CR_ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"owner":"kgod","name":"myrepo","ref":"main","sha":"<commit-sha>"}'
|
||||
```
|
||||
|
||||
## 行为说明
|
||||
|
||||
**审查范围**:PR 事件用 `base.sha..head.sha`;push 事件用 webhook 里的 `before..after`,如果 `before` 是新建分支的全零值,则退回到与 `base_branch` 的 merge-base。
|
||||
|
||||
**阻断判定**:一条意见命中任一条件即为阻断 —— 严重级别 ≥ `block_severity` 中任一项,类别在 `block_categories` 中,或打开了 `fail_on_findings`。阻断会创建 Issue、把提交状态置为 `failure`,并阻止自动合并。
|
||||
|
||||
**审查不完整时**:OCR 报告 `partial` / `failed`,或存在文件级警告,或 token 预算被截断时,仍会发布已有意见,但不会自动合并,提交状态也不会是 success。宁可漏合,不可错合。
|
||||
|
||||
**Issue 生命周期**:标题格式 `[OCR] <owner>/<repo> · <branch> 存在阻断级代码问题`。同一分支再次出现阻断问题时更新该 Issue 并追加一条说明;该分支复查通过后自动评论并关闭。`create_issue` 关闭时不新建 Issue,但仍会关闭此前开过的。
|
||||
|
||||
**重复保护**:同一 commit 已在队列中或正在审查时不重复入队;Gitea 重投的同一 delivery id 会被忽略。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
node --test tests/core.test.js # 单元测试
|
||||
node app/server.js # 本地直接运行(需先装 ocr CLI)
|
||||
docker compose build # 构建镜像
|
||||
```
|
||||
|
||||
本地运行需要 Node ≥ 22.5(用到内置 `node:sqlite`)、`git` ≥ 2.41,以及 `npm i -g @alibaba-group/open-code-review`。
|
||||
|
||||
## 故障排查
|
||||
|
||||
**Webhook 投递失败,提示 `webhook can only call allowed HTTP servers`**
|
||||
Gitea 拦截了内网地址。按上文给 `[webhook] ALLOWED_HOST_LIST` 加上本服务地址,重启 Gitea。
|
||||
|
||||
**后台打开后要求输入访问 Token**
|
||||
这是 `CR_ADMIN_TOKEN` 在生效。输入 `.env` 里的值即可,Token 只存在浏览器本地。想免登录就把它留空并重启容器(仅限不对外暴露的网络)。
|
||||
|
||||
**任务一直停在 `reviewing`**
|
||||
LLM 慢或不可达。在后台「设置」点「测试 LLM」,或在「任务」页查看日志;调大 `CR_REVIEW_TIMEOUT_MS`。
|
||||
|
||||
**内联评论变成了汇总里的条目**
|
||||
OCR 给的行号不在本次 diff 内(常见于问题定位到未改动的上下文行)。服务会保留意见并汇总展示,不丢结果。
|
||||
|
||||
**自动合并没有发生**
|
||||
看任务日志的 `auto-merge skipped` 原因:存在阻断问题、审查覆盖不完整、提交状态非 success、PR head 已变化或 PR 不可合并。Gitea 侧还需该 Token 有合并权限。
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const SCHEMA = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repositories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
base_branch TEXT NOT NULL DEFAULT 'main',
|
||||
branch_patterns TEXT NOT NULL DEFAULT '*',
|
||||
review_scope TEXT NOT NULL DEFAULT 'both',
|
||||
gitea_token TEXT,
|
||||
llm_provider TEXT,
|
||||
llm_model TEXT,
|
||||
llm_base_url TEXT,
|
||||
llm_token TEXT,
|
||||
rule_path TEXT,
|
||||
background_template TEXT,
|
||||
excludes TEXT,
|
||||
publish_mode TEXT NOT NULL DEFAULT 'inline',
|
||||
create_issue INTEGER NOT NULL DEFAULT 1,
|
||||
issue_labels TEXT NOT NULL DEFAULT 'code-review',
|
||||
block_severity TEXT NOT NULL DEFAULT 'critical,high',
|
||||
block_categories TEXT NOT NULL DEFAULT '',
|
||||
fail_on_findings INTEGER NOT NULL DEFAULT 0,
|
||||
auto_merge INTEGER NOT NULL DEFAULT 0,
|
||||
auto_merge_mode TEXT NOT NULL DEFAULT 'when_checks_succeed',
|
||||
merge_method TEXT NOT NULL DEFAULT 'squash',
|
||||
delete_branch INTEGER NOT NULL DEFAULT 0,
|
||||
max_comments INTEGER NOT NULL DEFAULT 30,
|
||||
concurrency INTEGER NOT NULL DEFAULT 4,
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (owner, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS branch_state (
|
||||
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
||||
ref_name TEXT NOT NULL,
|
||||
last_sha TEXT NOT NULL,
|
||||
reviewed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (repo_id, ref_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
||||
trigger TEXT NOT NULL,
|
||||
ref_name TEXT NOT NULL,
|
||||
base_ref TEXT,
|
||||
from_sha TEXT,
|
||||
to_sha TEXT NOT NULL,
|
||||
pr_number INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
phase TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
findings INTEGER NOT NULL DEFAULT 0,
|
||||
blocking INTEGER NOT NULL DEFAULT 0,
|
||||
comment_count INTEGER NOT NULL DEFAULT 0,
|
||||
issue_number INTEGER,
|
||||
merged INTEGER NOT NULL DEFAULT 0,
|
||||
result_json TEXT,
|
||||
error TEXT,
|
||||
log TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_repo ON jobs (repo_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_sha ON jobs (repo_id, to_sha);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
delivery_id TEXT PRIMARY KEY,
|
||||
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`;
|
||||
|
||||
export function openDatabase(path) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getSetting(db, key, fallback = null) {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
|
||||
return row ? row.value : fallback;
|
||||
}
|
||||
|
||||
export function setSetting(db, key, value) {
|
||||
db.prepare(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||||
).run(key, value == null ? "" : String(value));
|
||||
}
|
||||
|
||||
export function allSettings(db) {
|
||||
const out = {};
|
||||
for (const row of db.prepare("SELECT key, value FROM settings").all()) {
|
||||
out[row.key] = row.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function listRepositories(db) {
|
||||
return db.prepare("SELECT * FROM repositories ORDER BY owner, name").all();
|
||||
}
|
||||
|
||||
export function getRepository(db, id) {
|
||||
return db.prepare("SELECT * FROM repositories WHERE id = ?").get(id);
|
||||
}
|
||||
|
||||
export function findRepository(db, owner, name) {
|
||||
return db.prepare("SELECT * FROM repositories WHERE owner = ? AND name = ?").get(owner, name);
|
||||
}
|
||||
|
||||
const REPO_FIELDS = [
|
||||
"owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
|
||||
"gitea_token", "llm_provider", "llm_model", "llm_base_url", "llm_token",
|
||||
"rule_path", "background_template", "excludes", "publish_mode", "create_issue",
|
||||
"issue_labels", "block_severity", "block_categories", "fail_on_findings",
|
||||
"auto_merge", "auto_merge_mode", "merge_method", "delete_branch", "max_comments",
|
||||
"concurrency",
|
||||
];
|
||||
|
||||
export function upsertRepository(db, input) {
|
||||
const existing = input.id
|
||||
? getRepository(db, input.id)
|
||||
: findRepository(db, input.owner, input.name);
|
||||
const values = {};
|
||||
for (const field of REPO_FIELDS) {
|
||||
if (input[field] !== undefined) values[field] = input[field];
|
||||
}
|
||||
if (existing) {
|
||||
const sets = Object.keys(values).map((k) => `${k} = ?`);
|
||||
if (sets.length > 0) {
|
||||
db.prepare(
|
||||
`UPDATE repositories SET ${sets.join(", ")}, updated_at = datetime('now') WHERE id = ?`,
|
||||
).run(...Object.values(values), existing.id);
|
||||
}
|
||||
return getRepository(db, existing.id);
|
||||
}
|
||||
const cols = ["owner", "name", ...Object.keys(values)];
|
||||
const params = [input.owner, input.name, ...Object.values(values)];
|
||||
const placeholders = cols.map(() => "?").join(", ");
|
||||
const info = db.prepare(
|
||||
`INSERT INTO repositories (${cols.join(", ")}) VALUES (${placeholders})`,
|
||||
).run(...params);
|
||||
return getRepository(db, Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
export function deleteRepository(db, id) {
|
||||
db.prepare("DELETE FROM repositories WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export function enqueueJob(db, job) {
|
||||
const info = db.prepare(
|
||||
`INSERT INTO jobs (repo_id, trigger, ref_name, base_ref, from_sha, to_sha, pr_number, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued')`,
|
||||
).run(
|
||||
job.repoId, job.trigger, job.refName, job.baseRef ?? null,
|
||||
job.fromSha ?? null, job.toSha, job.prNumber ?? null,
|
||||
);
|
||||
return Number(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function claimNextJob(db) {
|
||||
const row = db.prepare(
|
||||
"SELECT * FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1",
|
||||
).get();
|
||||
if (!row) return null;
|
||||
const info = db.prepare(
|
||||
`UPDATE jobs SET status = 'running', attempts = attempts + 1,
|
||||
started_at = datetime('now'), phase = 'starting'
|
||||
WHERE id = ? AND status = 'queued'`,
|
||||
).run(row.id);
|
||||
if (info.changes === 0) return null;
|
||||
return db.prepare("SELECT * FROM jobs WHERE id = ?").get(row.id);
|
||||
}
|
||||
|
||||
export function updateJob(db, id, patch) {
|
||||
const allowed = [
|
||||
"status", "phase", "findings", "blocking", "comment_count",
|
||||
"issue_number", "merged", "result_json", "error", "log", "pr_number",
|
||||
];
|
||||
const keys = Object.keys(patch).filter((k) => allowed.includes(k));
|
||||
if (keys.length === 0) return;
|
||||
const sets = keys.map((k) => `${k} = ?`);
|
||||
if (patch.status && ["succeeded", "failed", "skipped"].includes(patch.status)) {
|
||||
sets.push("finished_at = datetime('now')");
|
||||
}
|
||||
db.prepare(`UPDATE jobs SET ${sets.join(", ")} WHERE id = ?`).run(
|
||||
...keys.map((k) => patch[k]), id,
|
||||
);
|
||||
}
|
||||
|
||||
export function getJob(db, id) {
|
||||
return db.prepare("SELECT * FROM jobs WHERE id = ?").get(id);
|
||||
}
|
||||
|
||||
export function listJobs(db, { repoId, limit = 50 } = {}) {
|
||||
if (repoId) {
|
||||
return db.prepare(
|
||||
"SELECT j.*, r.owner, r.name FROM jobs j JOIN repositories r ON r.id = j.repo_id " +
|
||||
"WHERE j.repo_id = ? ORDER BY j.id DESC LIMIT ?",
|
||||
).all(repoId, limit);
|
||||
}
|
||||
return db.prepare(
|
||||
"SELECT j.*, r.owner, r.name FROM jobs j JOIN repositories r ON r.id = j.repo_id " +
|
||||
"ORDER BY j.id DESC LIMIT ?",
|
||||
).all(limit);
|
||||
}
|
||||
|
||||
export function findJobBySha(db, repoId, sha) {
|
||||
return db.prepare(
|
||||
"SELECT * FROM jobs WHERE repo_id = ? AND to_sha = ? AND status IN ('queued','running') ORDER BY id DESC LIMIT 1",
|
||||
).get(repoId, sha);
|
||||
}
|
||||
|
||||
export function getBranchState(db, repoId, refName) {
|
||||
return db.prepare(
|
||||
"SELECT * FROM branch_state WHERE repo_id = ? AND ref_name = ?",
|
||||
).get(repoId, refName);
|
||||
}
|
||||
|
||||
export function setBranchState(db, repoId, refName, sha) {
|
||||
db.prepare(
|
||||
`INSERT INTO branch_state (repo_id, ref_name, last_sha, reviewed_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (repo_id, ref_name) DO UPDATE SET
|
||||
last_sha = excluded.last_sha, reviewed_at = datetime('now')`,
|
||||
).run(repoId, refName, sha);
|
||||
}
|
||||
|
||||
export function recordDelivery(db, deliveryId) {
|
||||
try {
|
||||
db.prepare("INSERT INTO webhook_deliveries (delivery_id) VALUES (?)").run(deliveryId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneDeliveries(db, keep = 2000) {
|
||||
db.prepare(
|
||||
`DELETE FROM webhook_deliveries WHERE delivery_id IN (
|
||||
SELECT delivery_id FROM webhook_deliveries ORDER BY received_at DESC LIMIT -1 OFFSET ?
|
||||
)`,
|
||||
).run(keep);
|
||||
}
|
||||
|
||||
export function jobStats(db) {
|
||||
const rows = db.prepare("SELECT status, COUNT(*) AS n FROM jobs GROUP BY status").all();
|
||||
const out = { queued: 0, running: 0, succeeded: 0, failed: 0, skipped: 0 };
|
||||
for (const r of rows) out[r.status] = r.n;
|
||||
return out;
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/** Parse unified git diffs into per-file hunks with old/new line maps. */
|
||||
|
||||
const FILE_HEADER = /^diff --git "?a\/(.+?)"? "?b\/(.+?)"?$/;
|
||||
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
||||
|
||||
function stripPrefixQuotes(value) {
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
return value.slice(1, -1).replace(/\\(.)/g, "$1");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a unified diff string.
|
||||
* Returns a Map of newPath -> { oldPath, status, hunks: [{ oldStart, oldLines,
|
||||
* newStart, newLines, newLineNumbers: number[], lines: string[] }] }.
|
||||
* `newLineNumbers[i]` is the new-file line number for hunk line i, or 0 for
|
||||
* removed lines / "\ No newline" markers.
|
||||
*/
|
||||
export function parseUnifiedDiff(diffText) {
|
||||
const files = new Map();
|
||||
if (!diffText) return files;
|
||||
|
||||
let current = null;
|
||||
let hunk = null;
|
||||
let newLine = 0;
|
||||
let oldLine = 0;
|
||||
|
||||
for (const raw of diffText.split("\n")) {
|
||||
const header = FILE_HEADER.exec(raw);
|
||||
if (header) {
|
||||
const newPath = stripPrefixQuotes(header[2]);
|
||||
current = {
|
||||
oldPath: stripPrefixQuotes(header[1]),
|
||||
newPath,
|
||||
status: "modified",
|
||||
hunks: [],
|
||||
};
|
||||
files.set(newPath, current);
|
||||
hunk = null;
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
|
||||
if (raw.startsWith("new file mode")) { current.status = "added"; continue; }
|
||||
if (raw.startsWith("deleted file mode")) { current.status = "deleted"; continue; }
|
||||
if (raw.startsWith("rename to ")) { current.newPath = stripPrefixQuotes(raw.slice(10).trim()); continue; }
|
||||
if (raw.startsWith("--- ") || raw.startsWith("+++ ") || raw.startsWith("index ")
|
||||
|| raw.startsWith("similarity index") || raw.startsWith("rename from")
|
||||
|| raw.startsWith("old mode") || raw.startsWith("new mode")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const h = HUNK_HEADER.exec(raw);
|
||||
if (h) {
|
||||
oldLine = Number(h[1]);
|
||||
newLine = Number(h[3]);
|
||||
hunk = {
|
||||
oldStart: oldLine,
|
||||
oldLines: h[2] === undefined ? 1 : Number(h[2]),
|
||||
newStart: newLine,
|
||||
newLines: h[4] === undefined ? 1 : Number(h[4]),
|
||||
newLineNumbers: [],
|
||||
lines: [],
|
||||
};
|
||||
current.hunks.push(hunk);
|
||||
continue;
|
||||
}
|
||||
if (!hunk) continue;
|
||||
|
||||
hunk.lines.push(raw);
|
||||
if (raw.startsWith("+")) {
|
||||
hunk.newLineNumbers.push(newLine);
|
||||
newLine += 1;
|
||||
} else if (raw.startsWith("-")) {
|
||||
hunk.newLineNumbers.push(0);
|
||||
oldLine += 1;
|
||||
} else if (raw.startsWith("\\")) {
|
||||
hunk.newLineNumbers.push(0);
|
||||
} else {
|
||||
hunk.newLineNumbers.push(newLine);
|
||||
newLine += 1;
|
||||
oldLine += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/** All new-file line numbers present in the diff for a given path. */
|
||||
export function addedLineNumbers(fileEntry) {
|
||||
const added = new Set();
|
||||
for (const hunk of fileEntry.hunks) {
|
||||
for (let i = 0; i < hunk.lines.length; i += 1) {
|
||||
if (hunk.lines[i].startsWith("+")) {
|
||||
const line = hunk.newLineNumbers[i];
|
||||
if (line > 0) added.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/** All new-file line numbers in any hunk (added + context) for a path. */
|
||||
export function diffLineNumbers(fileEntry) {
|
||||
const lines = new Set();
|
||||
for (const hunk of fileEntry.hunks) {
|
||||
for (let i = 0; i < hunk.lines.length; i += 1) {
|
||||
const line = hunk.newLineNumbers[i];
|
||||
if (line > 0) lines.add(line);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best inline anchor for a finding.
|
||||
* Prefers a line inside the diff (Gitea renders those inline); falls back to
|
||||
* the start line so the finding is still recorded on the pull request.
|
||||
*/
|
||||
export function pickAnchorLine(fileEntry, startLine, endLine) {
|
||||
const inDiff = diffLineNumbers(fileEntry);
|
||||
const added = addedLineNumbers(fileEntry);
|
||||
const lo = Math.max(1, Math.min(startLine || endLine || 1, endLine || startLine || 1));
|
||||
const hi = Math.max(startLine || endLine || 1, endLine || startLine || 1);
|
||||
for (let line = lo; line <= hi; line += 1) {
|
||||
if (added.has(line)) return { line, inDiff: true };
|
||||
}
|
||||
for (let line = lo; line <= hi; line += 1) {
|
||||
if (inDiff.has(line)) return { line, inDiff: true };
|
||||
}
|
||||
return { line: lo, inDiff: false };
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/** 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}`);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
/** Runs the OpenCodeReview CLI and parses its JSON output. */
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export class OcrError extends Error {
|
||||
constructor(message, { exitCode, stderr, stdout } = {}) {
|
||||
super(message);
|
||||
this.name = "OcrError";
|
||||
this.exitCode = exitCode;
|
||||
this.stderr = stderr;
|
||||
this.stdout = stdout;
|
||||
}
|
||||
}
|
||||
|
||||
export const CATEGORY_VALUES = [
|
||||
"bug", "security", "performance", "maintainability",
|
||||
"test", "style", "documentation", "other",
|
||||
];
|
||||
export const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
|
||||
const MAX_CAPTURE = 2 * 1024 * 1024;
|
||||
|
||||
function run(command, args, { cwd, env, timeoutMs, onOutput } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, { cwd, env, windowsHide: true });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timer = timeoutMs
|
||||
? setTimeout(() => {
|
||||
try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
||||
finish({ code: 124, stdout, stderr: `${stderr}\n[timeout after ${timeoutMs} ms]` });
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
const text = chunk.toString();
|
||||
if (stdout.length < MAX_CAPTURE) stdout += text;
|
||||
onOutput?.("stdout", text);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
const text = chunk.toString();
|
||||
if (stderr.length < MAX_CAPTURE) stderr += text;
|
||||
onOutput?.("stderr", text);
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
finish({ code: 127, stdout, stderr: `${stderr}\n${err.message}` });
|
||||
});
|
||||
child.on("close", (code) => finish({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function git(args, { cwd, timeoutMs = 300000 } = {}) {
|
||||
return run("git", args, { cwd, timeoutMs });
|
||||
}
|
||||
|
||||
export class OcrRunner {
|
||||
constructor({
|
||||
command = "ocr",
|
||||
workspaceDir,
|
||||
llm = {},
|
||||
timeoutMs = 45 * 60 * 1000,
|
||||
gitTimeoutMs = 10 * 60 * 1000,
|
||||
logger = () => {},
|
||||
} = {}) {
|
||||
this.command = command;
|
||||
this.workspaceDir = workspaceDir;
|
||||
this.llm = llm;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.gitTimeoutMs = gitTimeoutMs;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
ocrEnv(extra = {}) {
|
||||
const env = { ...process.env, ...extra };
|
||||
if (this.llm.url) env.OCR_LLM_URL = this.llm.url;
|
||||
if (this.llm.token) env.OCR_LLM_TOKEN = this.llm.token;
|
||||
if (this.llm.model) env.OCR_LLM_MODEL = this.llm.model;
|
||||
if (this.llm.protocol) env.OCR_LLM_PROTOCOL = this.llm.protocol;
|
||||
if (this.llm.authHeader) env.OCR_LLM_AUTH_HEADER = this.llm.authHeader;
|
||||
if (this.llm.extraHeaders) env.OCR_LLM_EXTRA_HEADERS = this.llm.extraHeaders;
|
||||
if (this.llm.timeoutSeconds) env.OCR_LLM_TIMEOUT = String(this.llm.timeoutSeconds);
|
||||
env.OCR_ENABLE_TELEMETRY = "0";
|
||||
return env;
|
||||
}
|
||||
|
||||
/** Verify the OCR binary and the configured LLM endpoint. */
|
||||
async selfTest() {
|
||||
const version = await run(this.command, ["version"], {
|
||||
env: this.ocrEnv(), timeoutMs: 60000,
|
||||
});
|
||||
if (version.code !== 0) {
|
||||
throw new OcrError(`cannot run '${this.command} version'`, {
|
||||
exitCode: version.code, stderr: version.stderr, stdout: version.stdout,
|
||||
});
|
||||
}
|
||||
const test = await run(this.command, ["llm", "test"], {
|
||||
env: this.ocrEnv(), timeoutMs: 120000,
|
||||
});
|
||||
return {
|
||||
version: version.stdout.trim(),
|
||||
llmOk: test.code === 0,
|
||||
llmOutput: `${test.stdout}\n${test.stderr}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async gitClone({ cloneUrl, token, dir, extraHeader = true }) {
|
||||
const args = ["clone", "--no-tags", "--filter=blob:none"];
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
if (token) {
|
||||
if (extraHeader) {
|
||||
env.GIT_CONFIG_COUNT = "1";
|
||||
env.GIT_CONFIG_KEY_0 = "http.extraHeader";
|
||||
env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
|
||||
} else {
|
||||
const url = new URL(cloneUrl);
|
||||
url.username = "oauth2";
|
||||
url.password = token;
|
||||
cloneUrl = url.toString();
|
||||
}
|
||||
}
|
||||
args.push(cloneUrl, dir);
|
||||
const res = await run("git", args, { env, timeoutMs: this.gitTimeoutMs });
|
||||
if (res.code !== 0) {
|
||||
throw new OcrError(`git clone failed for ${cloneUrl}`, {
|
||||
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
|
||||
});
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
async fetch(dir, { refs = [], token } = {}) {
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
if (token) {
|
||||
env.GIT_CONFIG_COUNT = "1";
|
||||
env.GIT_CONFIG_KEY_0 = "http.extraHeader";
|
||||
env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
|
||||
}
|
||||
const args = ["fetch", "--prune", "--no-tags", "origin"];
|
||||
for (const ref of refs) args.push(ref);
|
||||
const res = await run("git", args, { cwd: dir, env, timeoutMs: this.gitTimeoutMs });
|
||||
if (res.code !== 0) {
|
||||
throw new OcrError(`git fetch failed in ${dir}`, {
|
||||
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async revParse(dir, ref) {
|
||||
const res = await git(["rev-parse", "--verify", `${ref}^{commit}`], { cwd: dir, timeoutMs: 60000 });
|
||||
if (res.code !== 0) return null;
|
||||
return res.stdout.trim().split("\n")[0];
|
||||
}
|
||||
|
||||
async mergeBase(dir, a, b) {
|
||||
const res = await git(["merge-base", a, b], { cwd: dir, timeoutMs: 120000 });
|
||||
if (res.code !== 0) return null;
|
||||
return res.stdout.trim().split("\n")[0];
|
||||
}
|
||||
|
||||
async changedFiles(dir, fromSha, toSha) {
|
||||
const res = await git(
|
||||
["diff", "--name-only", "--diff-filter=ACMRTUXB", fromSha, toSha],
|
||||
{ cwd: dir, timeoutMs: this.gitTimeoutMs },
|
||||
);
|
||||
if (res.code !== 0) return [];
|
||||
return res.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a diff review.
|
||||
* @returns {{ comments: object[], summary: object|null, raw: object, stderr: string }}
|
||||
*/
|
||||
async review({
|
||||
dir, fromSha, toSha, excludes = [], background, concurrency = 4,
|
||||
maxComments = 30, maxTokensBudget = 0, rulePath, onOutput,
|
||||
}) {
|
||||
const outFile = join(await mkdtemp(join(tmpdir(), "ocr-out-")), "result.json");
|
||||
const args = [
|
||||
"review",
|
||||
"--repo", dir,
|
||||
"--from", fromSha,
|
||||
"--to", toSha,
|
||||
"--format", "json",
|
||||
"--audience", "agent",
|
||||
"--concurrency", String(concurrency),
|
||||
"--output", outFile,
|
||||
];
|
||||
if (excludes.length) args.push("--exclude", excludes.join(","));
|
||||
if (background) args.push("--background", background);
|
||||
if (rulePath && existsSync(rulePath)) args.push("--rule", rulePath);
|
||||
if (maxTokensBudget > 0) args.push("--max-tokens-budget", String(maxTokensBudget));
|
||||
|
||||
this.logger(`ocr review --from ${fromSha} --to ${toSha} (cwd=${dir})`);
|
||||
const res = await run(this.command, args, {
|
||||
cwd: dir, env: this.ocrEnv(), timeoutMs: this.timeoutMs, onOutput,
|
||||
});
|
||||
|
||||
let raw = null;
|
||||
try {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
raw = JSON.parse(await readFile(outFile, "utf8"));
|
||||
} catch (err) {
|
||||
if (res.code !== 0) {
|
||||
throw new OcrError(`ocr review failed (exit ${res.code})`, {
|
||||
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
|
||||
});
|
||||
}
|
||||
throw new OcrError(`cannot parse ocr JSON output: ${err.message}`, {
|
||||
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
|
||||
});
|
||||
} finally {
|
||||
await rm(outFile, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
const comments = Array.isArray(raw?.comments) ? raw.comments : [];
|
||||
const selected = comments
|
||||
.filter((c) => c && typeof c.path === "string" && c.path.length > 0)
|
||||
.slice(0, Math.max(1, maxComments));
|
||||
return {
|
||||
comments: selected,
|
||||
totalComments: comments.length,
|
||||
summary: raw?.summary ?? null,
|
||||
status: raw?.status ?? null,
|
||||
projectSummary: raw?.project_summary ?? "",
|
||||
warnings: raw?.warnings ?? [],
|
||||
raw,
|
||||
stderr: res.stderr,
|
||||
exitCode: res.code,
|
||||
};
|
||||
}
|
||||
|
||||
async preview({ dir, fromSha, toSha, excludes = [], onOutput }) {
|
||||
const args = [
|
||||
"review", "--repo", dir, "--from", fromSha, "--to", toSha,
|
||||
"--format", "json", "--audience", "agent", "--preview",
|
||||
];
|
||||
if (excludes.length) args.push("--exclude", excludes.join(","));
|
||||
const res = await run(this.command, args, {
|
||||
cwd: dir, env: this.ocrEnv(), timeoutMs: 300000, onOutput,
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new OcrError(`ocr review --preview failed (exit ${res.code})`, {
|
||||
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
|
||||
});
|
||||
}
|
||||
return JSON.parse(res.stdout);
|
||||
}
|
||||
}
|
||||
|
||||
export function severityAtLeast(severity, threshold) {
|
||||
const a = SEVERITY_RANK[String(severity || "").toLowerCase()] ?? 0;
|
||||
const b = SEVERITY_RANK[String(threshold || "").toLowerCase()] ?? 0;
|
||||
return a > 0 && b > 0 && a >= b;
|
||||
}
|
||||
|
||||
export function parseList(value) {
|
||||
if (!value) return [];
|
||||
return String(value).split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Sequential job worker with retry and stale-job recovery. */
|
||||
import { claimNextJob, updateJob } from "./db.js";
|
||||
import { SkipJob } from "./review.js";
|
||||
|
||||
const STALE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
export class JobQueue {
|
||||
constructor({ db, engine, logger = console, pollMs = 3000, maxAttempts = 2 }) {
|
||||
this.db = db;
|
||||
this.engine = engine;
|
||||
this.logger = logger;
|
||||
this.pollMs = pollMs;
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.running = false;
|
||||
this.busy = false;
|
||||
this.timer = null;
|
||||
this.currentJobId = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.recoverStale();
|
||||
this.tick();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
recoverStale() {
|
||||
const cutoff = new Date(Date.now() - STALE_MS).toISOString().replace("T", " ").slice(0, 19);
|
||||
const stale = this.db.prepare(
|
||||
"SELECT id FROM jobs WHERE status = 'running' AND started_at IS NOT NULL AND started_at < ?",
|
||||
).all(cutoff);
|
||||
for (const row of stale) {
|
||||
updateJob(this.db, row.id, {
|
||||
status: "queued", phase: null,
|
||||
error: "requeued after worker restart or timeout",
|
||||
});
|
||||
this.logger.warn?.(`requeued stale job #${row.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async tick() {
|
||||
if (!this.running) return;
|
||||
if (this.busy) {
|
||||
this.timer = setTimeout(() => this.tick(), this.pollMs);
|
||||
return;
|
||||
}
|
||||
const job = claimNextJob(this.db);
|
||||
if (!job) {
|
||||
this.timer = setTimeout(() => this.tick(), this.pollMs);
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.currentJobId = job.id;
|
||||
try {
|
||||
await this.engine.execute(job);
|
||||
} catch (err) {
|
||||
if (err instanceof SkipJob) {
|
||||
await this.engine.failJob(job, err);
|
||||
} else if (job.attempts < this.maxAttempts) {
|
||||
this.logger.warn?.(`job #${job.id} failed (attempt ${job.attempts}): ${err.message}; retrying`);
|
||||
updateJob(this.db, job.id, {
|
||||
status: "queued", phase: null,
|
||||
error: `${err.name || "Error"}: ${err.message}`,
|
||||
});
|
||||
} else {
|
||||
await this.engine.failJob(job, err);
|
||||
}
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.currentJobId = null;
|
||||
}
|
||||
this.timer = setTimeout(() => this.tick(), this.pollMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* Core review pipeline: fetch -> diff -> OCR -> publish -> gate -> merge.
|
||||
*/
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { GiteaClient, webBaseUrl, normalizeBaseUrl } from "./gitea.js";
|
||||
import { OcrRunner, parseList, severityAtLeast } from "./ocr.js";
|
||||
import { parseUnifiedDiff, pickAnchorLine } from "./diff.js";
|
||||
import { getBranchState, setBranchState, updateJob } from "./db.js";
|
||||
|
||||
export const SUMMARY_MARKER = "<!-- gitea-codereview:summary -->";
|
||||
export const COMMENT_MARKER = "<!-- gitea-codereview -->";
|
||||
export const STATUS_CONTEXT = "code-review/ocr";
|
||||
|
||||
export class SkipJob extends Error {
|
||||
constructor(reason) {
|
||||
super(reason);
|
||||
this.name = "SkipJob";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
function repoSlug(repo) {
|
||||
return `${repo.owner}/${repo.name}`;
|
||||
}
|
||||
|
||||
function globToRegExp(pattern) {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
const body = escaped
|
||||
.replace(/\*\*/g, "\u0000")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\?/g, ".")
|
||||
.replace(/\u0000/g, ".*");
|
||||
return new RegExp(`^${body}$`);
|
||||
}
|
||||
|
||||
export function branchMatches(refName, patterns) {
|
||||
const list = parseList(patterns);
|
||||
if (list.length === 0 || list.includes("*")) return true;
|
||||
const short = refName.replace(/^refs\/heads\//, "");
|
||||
return list.some((p) => globToRegExp(p).test(short) || globToRegExp(p).test(refName));
|
||||
}
|
||||
|
||||
function badge(comment) {
|
||||
const parts = [comment.category, comment.severity].filter(Boolean);
|
||||
return parts.length ? `[${parts.join(" · ")}] ` : "";
|
||||
}
|
||||
|
||||
function renderCommentBody(comment) {
|
||||
const lines = [`${badge(comment)}${String(comment.content || "").trim()}`];
|
||||
if (comment.suggestion_code) {
|
||||
lines.push("", "```suggestion", String(comment.suggestion_code).replace(/\n+$/, ""), "```");
|
||||
}
|
||||
lines.push("", COMMENT_MARKER);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderSummaryBody({ repo, job, review, published, failed, blocking, note }) {
|
||||
const lines = [SUMMARY_MARKER, "## OCR 代码审查", ""];
|
||||
lines.push(`- 仓库:\`${repoSlug(repo)}\``);
|
||||
lines.push(`- 分支:\`${job.ref_name}\``);
|
||||
if (job.pr_number) lines.push(`- Pull Request:#${job.pr_number}`);
|
||||
lines.push(`- 提交:\`${String(job.to_sha).slice(0, 10)}\``);
|
||||
if (review.summary) {
|
||||
const s = review.summary;
|
||||
lines.push(
|
||||
`- 审查:${s.files_reviewed ?? "?"} 个文件 / ${s.comments ?? 0} 条意见` +
|
||||
`${s.total_tokens ? ` / ${s.total_tokens} tokens` : ""}` +
|
||||
`${s.elapsed ? ` / ${s.elapsed}` : ""}`,
|
||||
);
|
||||
}
|
||||
if (note) lines.push("", note);
|
||||
lines.push("");
|
||||
|
||||
if (published.length === 0) {
|
||||
lines.push("未发现需要处理的问题。");
|
||||
} else {
|
||||
lines.push(`### 审查意见(${published.length} 条)`, "");
|
||||
const grouped = new Map();
|
||||
for (const item of published) {
|
||||
const key = item.comment.path;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(item);
|
||||
}
|
||||
for (const [path, items] of grouped) {
|
||||
lines.push(`**\`${path}\`**`, "");
|
||||
for (const item of items) {
|
||||
const where = item.comment.start_line
|
||||
? `L${item.comment.start_line}${item.comment.end_line && item.comment.end_line !== item.comment.start_line ? `-${item.comment.end_line}` : ""}`
|
||||
: "位置未知";
|
||||
const flag = item.inline ? "" : "(无法内联定位)";
|
||||
lines.push(`- ${badge(item.comment)}${where}${flag} — ${String(item.comment.content || "").replace(/\s*\n\s*/g, " ").trim()}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
lines.push(`### 发布失败(${failed.length} 条)`, "");
|
||||
for (const item of failed) {
|
||||
lines.push(`- \`${item.comment.path}\` — ${item.error}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (blocking.length > 0) {
|
||||
lines.push(
|
||||
"### 结论",
|
||||
"",
|
||||
`存在 ${blocking.length} 条达到阻断阈值的问题,已创建/更新 Issue,且不会自动合并。`,
|
||||
);
|
||||
} else if (published.length > 0) {
|
||||
lines.push("### 结论", "", "未发现达到阻断阈值的问题。");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
`<sub>由 gitea-codereview 基于 [OpenCodeReview](https://github.com/alibaba/open-code-review) 生成 · job #${job.id}</sub>`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderIssueBody({ repo, job, blocking, summaryUrl }) {
|
||||
const lines = [
|
||||
SUMMARY_MARKER,
|
||||
`自动代码审查在 \`${job.ref_name}\` @ \`${String(job.to_sha).slice(0, 10)}\` 上发现阻断级问题。`,
|
||||
"",
|
||||
`- 仓库:\`${repoSlug(repo)}\``,
|
||||
`- 分支:\`${job.ref_name}\``,
|
||||
`- 提交:\`${job.to_sha}\``,
|
||||
];
|
||||
if (job.pr_number) lines.push(`- Pull Request:#${job.pr_number}`);
|
||||
if (summaryUrl) lines.push(`- 审查详情:${summaryUrl}`);
|
||||
lines.push("", `### 阻断问题(${blocking.length} 条)`, "");
|
||||
for (const item of blocking) {
|
||||
const c = item.comment;
|
||||
const where = c.start_line ? `${c.path}:${c.start_line}` : c.path;
|
||||
lines.push(`- ${badge(c)}\`${where}\` — ${String(c.content || "").replace(/\s*\n\s*/g, " ").trim()}`);
|
||||
}
|
||||
lines.push("", `<sub>job #${job.id} · 由 gitea-codereview 生成</sub>`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Resolve the fetch/review range for a job against the workspace clone. */
|
||||
async function resolveRange(runner, { repo, job, workspace, token }) {
|
||||
const headRef = `refs/heads/${job.ref_name}`;
|
||||
const refs = [headRef, `+${headRef}:refs/remotes/origin/${job.ref_name}`];
|
||||
if (job.base_ref) refs.push(`+refs/heads/${job.base_ref}:refs/remotes/origin/${job.base_ref}`);
|
||||
if (job.pr_number) refs.push(`+refs/pull/${job.pr_number}/head:refs/remotes/origin/pr/${job.pr_number}`);
|
||||
await runner.fetch(workspace, { refs, token });
|
||||
|
||||
const toSha = job.to_sha;
|
||||
const local = await runner.revParse(workspace, toSha);
|
||||
if (!local) {
|
||||
throw new Error(`commit ${toSha} not found in workspace after fetch`);
|
||||
}
|
||||
|
||||
let fromSha = null;
|
||||
if (job.from_sha) {
|
||||
fromSha = await runner.revParse(workspace, job.from_sha);
|
||||
}
|
||||
if (!fromSha && job.base_ref) {
|
||||
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${job.base_ref}`);
|
||||
if (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha;
|
||||
}
|
||||
if (!fromSha) {
|
||||
const parent = await runner.revParse(workspace, `${toSha}^`);
|
||||
if (parent) fromSha = parent;
|
||||
}
|
||||
if (!fromSha) {
|
||||
throw new SkipJob(`no base commit available for ${toSha.slice(0, 10)} (initial commit)`);
|
||||
}
|
||||
if (fromSha === toSha) {
|
||||
throw new SkipJob("base and head resolve to the same commit (empty change set)");
|
||||
}
|
||||
return { fromSha, toSha };
|
||||
}
|
||||
|
||||
export class ReviewEngine {
|
||||
constructor({ db, config, logger = console }) {
|
||||
this.db = db;
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
this.log = (msg) => logger.info?.(msg) ?? console.log(msg);
|
||||
}
|
||||
|
||||
globalLlm() {
|
||||
return {
|
||||
url: this.config.llmUrl,
|
||||
token: this.config.llmToken,
|
||||
model: this.config.llmModel,
|
||||
protocol: this.config.llmProtocol,
|
||||
authHeader: this.config.llmAuthHeader,
|
||||
extraHeaders: this.config.llmExtraHeaders,
|
||||
timeoutSeconds: this.config.llmTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
repoLlm(repo) {
|
||||
const base = this.globalLlm();
|
||||
return {
|
||||
...base,
|
||||
url: repo.llm_base_url || base.url,
|
||||
token: repo.llm_token || base.token,
|
||||
model: repo.llm_model || base.model,
|
||||
protocol: repo.llm_provider || base.protocol,
|
||||
};
|
||||
}
|
||||
|
||||
clientFor(repo) {
|
||||
return new GiteaClient({
|
||||
baseUrl: normalizeBaseUrl(this.config.giteaUrl),
|
||||
token: repo.gitea_token || this.config.giteaToken,
|
||||
timeoutMs: this.config.httpTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
async ensureWorkspace(repo) {
|
||||
const root = join(this.config.dataDir, "workspaces");
|
||||
await mkdir(root, { recursive: true });
|
||||
const dir = join(root, `${repo.owner}__${repo.name}`);
|
||||
if (existsSync(join(dir, ".git"))) return dir;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
const cloneUrl = `${normalizeBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}.git`;
|
||||
const runner = new OcrRunner({ command: this.config.ocrCommand, logger: this.log });
|
||||
await runner.gitClone({ cloneUrl, token: repo.gitea_token || this.config.giteaToken, dir });
|
||||
return dir;
|
||||
}
|
||||
|
||||
async execute(job) {
|
||||
const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(job.repo_id);
|
||||
if (!repo) throw new Error(`repository ${job.repo_id} no longer exists`);
|
||||
if (!repo.enabled) throw new SkipJob("repository disabled");
|
||||
|
||||
const client = this.clientFor(repo);
|
||||
const runner = new OcrRunner({
|
||||
command: this.config.ocrCommand,
|
||||
llm: this.repoLlm(repo),
|
||||
timeoutMs: this.config.reviewTimeoutMs,
|
||||
logger: this.log,
|
||||
});
|
||||
|
||||
const logs = [];
|
||||
const appendLog = (line) => {
|
||||
logs.push(line);
|
||||
if (logs.length > 400) logs.shift();
|
||||
};
|
||||
const setPhase = (phase, patch = {}) => {
|
||||
updateJob(this.db, job.id, { phase, log: logs.join("\n"), ...patch });
|
||||
};
|
||||
|
||||
setPhase("preparing");
|
||||
const workspace = await this.ensureWorkspace(repo);
|
||||
const { fromSha, toSha } = await resolveRange(runner, {
|
||||
repo, job, workspace, token: repo.gitea_token || this.config.giteaToken,
|
||||
});
|
||||
appendLog(`range ${fromSha}..${toSha}`);
|
||||
|
||||
const excludes = parseList(repo.excludes);
|
||||
setPhase("reviewing");
|
||||
const review = await runner.review({
|
||||
dir: workspace,
|
||||
fromSha,
|
||||
toSha,
|
||||
excludes,
|
||||
background: (repo.background_template || "").trim() || undefined,
|
||||
concurrency: repo.concurrency || this.config.defaultConcurrency,
|
||||
maxComments: repo.max_comments || 30,
|
||||
maxTokensBudget: this.config.maxTokensBudget || 0,
|
||||
rulePath: repo.rule_path || this.config.rulePath || undefined,
|
||||
onOutput: (stream, text) => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed) appendLog(`${stream === "stderr" ? "[stderr] " : ""}${trimmed}`);
|
||||
},
|
||||
});
|
||||
updateJob(this.db, job.id, { log: logs.join("\n") });
|
||||
|
||||
// Build the diff map so findings can be anchored to real diff lines.
|
||||
let diffText = "";
|
||||
try {
|
||||
diffText = await this.gitDiff(workspace, fromSha, toSha);
|
||||
} catch (err) {
|
||||
appendLog(`diff fetch failed: ${err.message}`);
|
||||
}
|
||||
const diffFiles = parseUnifiedDiff(diffText);
|
||||
|
||||
const published = [];
|
||||
const failed = [];
|
||||
for (const comment of review.comments) {
|
||||
const entry = diffFiles.get(comment.path);
|
||||
if (!entry) {
|
||||
failed.push({ comment, error: "文件不在本次 diff 中,已跳过" });
|
||||
continue;
|
||||
}
|
||||
const anchor = pickAnchorLine(entry, comment.start_line, comment.end_line);
|
||||
published.push({ comment, anchor, inline: anchor.inDiff });
|
||||
}
|
||||
|
||||
// A finding blocks auto-merge (and turns the commit status red) when it
|
||||
// meets the severity threshold, matches a blocked category, or when the
|
||||
// repository opts into failing on any finding at all.
|
||||
const blockSeverities = parseList(repo.block_severity);
|
||||
const blockCategories = parseList(repo.block_categories);
|
||||
const failOnFindings = Boolean(repo.fail_on_findings);
|
||||
const blocking = published.filter(({ comment }) => {
|
||||
if (failOnFindings) return true;
|
||||
const sevHit = blockSeverities.some((s) => severityAtLeast(comment.severity, s));
|
||||
const catHit = blockCategories.includes(String(comment.category || "").toLowerCase());
|
||||
return sevHit || catHit;
|
||||
});
|
||||
|
||||
// A partial or degraded review must never gate a merge: OCR reports
|
||||
// warnings when whole files could not be reviewed, and merging on an
|
||||
// incomplete result is exactly the failure mode this service must avoid.
|
||||
// OCR terminal states: complete | partial | failed | skipped, plus the
|
||||
// legacy "success" / "completed_with_warnings" spellings.
|
||||
// "skipped" means the diff contained no reviewable file at all, which is a
|
||||
// clean outcome rather than partial coverage; "partial" / "failed" mean
|
||||
// some selected files were never reviewed and must not gate a merge.
|
||||
const CLEAN_STATUSES = new Set(["complete", "success", "skipped"]);
|
||||
const reviewIncomplete = Boolean(review.summary?.budget_exceeded)
|
||||
|| (Array.isArray(review.warnings) && review.warnings.length > 0)
|
||||
|| !CLEAN_STATUSES.has(review.status ?? "complete");
|
||||
if (reviewIncomplete) {
|
||||
appendLog(`review reported incomplete coverage (status=${review.status ?? "?"}, warnings=${review.warnings?.length ?? 0})`);
|
||||
}
|
||||
|
||||
setPhase("publishing");
|
||||
const prNumber = job.pr_number ?? (await this.findPullRequestForSha(client, repo, toSha, job.ref_name));
|
||||
if (prNumber && !job.pr_number) {
|
||||
updateJob(this.db, job.id, { pr_number: prNumber });
|
||||
job.pr_number = prNumber;
|
||||
}
|
||||
|
||||
let inlinePosted = 0;
|
||||
let reviewBody = "";
|
||||
// Publishing to a merged or closed PR would be noise; keep the findings in
|
||||
// the job record instead.
|
||||
let prIsOpen = Boolean(prNumber);
|
||||
if (prIsOpen) {
|
||||
try {
|
||||
const pr = await client.getPullRequest(repo.owner, repo.name, prNumber);
|
||||
prIsOpen = pr?.state === "open" && !pr?.merged;
|
||||
} catch (err) {
|
||||
appendLog(`cannot load PR #${prNumber}: ${err.message}`);
|
||||
prIsOpen = false;
|
||||
}
|
||||
}
|
||||
if (prIsOpen && repo.publish_mode !== "issue-only") {
|
||||
const inline = published.filter((p) => p.inline);
|
||||
reviewBody = renderSummaryBody({
|
||||
repo, job, review, published, failed, blocking,
|
||||
note: inline.length < published.length
|
||||
? `${published.length - inline.length} 条意见无法定位到本次 diff 的行,已汇总在本评论中。`
|
||||
: "",
|
||||
});
|
||||
try {
|
||||
await client.createPullReview(repo.owner, repo.name, prNumber, {
|
||||
event: "COMMENT",
|
||||
body: reviewBody,
|
||||
commitId: toSha,
|
||||
comments: inline.map((p) => ({
|
||||
path: p.comment.path,
|
||||
newPosition: p.anchor.line,
|
||||
body: renderCommentBody(p.comment),
|
||||
})),
|
||||
});
|
||||
inlinePosted = inline.length;
|
||||
appendLog(`posted pull review with ${inlinePosted} inline comment(s)`);
|
||||
} catch (err) {
|
||||
appendLog(`pull review failed: ${err.message}`);
|
||||
// Fall back to an issue comment so the findings are not lost.
|
||||
try {
|
||||
await client.createIssueComment(repo.owner, repo.name, prNumber, reviewBody);
|
||||
appendLog("posted summary as issue comment instead");
|
||||
} catch (fallbackErr) {
|
||||
appendLog(`issue comment fallback failed: ${fallbackErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue lifecycle: one open issue per repository+ref, updated in place.
|
||||
// When issue creation is disabled the service still closes any issue it
|
||||
// previously opened once the ref is clean, so stale issues do not linger.
|
||||
let issueNumber = null;
|
||||
const labels = parseList(repo.issue_labels);
|
||||
const issueTitle = `[OCR] ${repoSlug(repo)} · ${job.ref_name} 存在阻断级代码问题`;
|
||||
try {
|
||||
issueNumber = await this.upsertIssue({
|
||||
client, repo, job, blocking, labels, title: issueTitle,
|
||||
createEnabled: Boolean(repo.create_issue),
|
||||
summaryUrl: prNumber
|
||||
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
|
||||
: `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/commit/${toSha}`,
|
||||
body: renderIssueBody({
|
||||
repo, job, blocking,
|
||||
summaryUrl: prNumber ? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}` : "",
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
appendLog(`issue upsert failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Commit status so branch protection can require this context.
|
||||
const state = blocking.length > 0 ? "failure" : "success";
|
||||
try {
|
||||
await client.createCommitStatus(repo.owner, repo.name, toSha, {
|
||||
state,
|
||||
context: STATUS_CONTEXT,
|
||||
description: blocking.length > 0
|
||||
? `${blocking.length} blocking issue(s), ${published.length} total`
|
||||
: published.length > 0
|
||||
? `${published.length} comment(s), none blocking`
|
||||
: "no issues found",
|
||||
targetUrl: prNumber
|
||||
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
|
||||
: "",
|
||||
});
|
||||
appendLog(`commit status ${state} (${STATUS_CONTEXT})`);
|
||||
} catch (err) {
|
||||
appendLog(`commit status failed: ${err.message}`);
|
||||
}
|
||||
|
||||
setPhase("finalizing");
|
||||
const merged = await this.maybeAutoMerge({
|
||||
client, repo, job, prNumber, blocking, toSha, appendLog,
|
||||
reviewIncomplete, statusState: state,
|
||||
});
|
||||
|
||||
const result = {
|
||||
fromSha, toSha, prNumber, issueNumber,
|
||||
comments: published.length,
|
||||
inlineComments: inlinePosted,
|
||||
blocking: blocking.length,
|
||||
failed: failed.length,
|
||||
merged,
|
||||
reviewStatus: review.status,
|
||||
summary: review.summary,
|
||||
warnings: review.warnings,
|
||||
};
|
||||
|
||||
updateJob(this.db, job.id, {
|
||||
status: "succeeded",
|
||||
phase: "done",
|
||||
findings: published.length,
|
||||
blocking: blocking.length,
|
||||
comment_count: inlinePosted,
|
||||
issue_number: issueNumber,
|
||||
merged: merged ? 1 : 0,
|
||||
result_json: JSON.stringify(result),
|
||||
log: logs.join("\n"),
|
||||
});
|
||||
setBranchState(this.db, repo.id, job.ref_name, toSha);
|
||||
return result;
|
||||
}
|
||||
|
||||
async gitDiff(dir, fromSha, toSha) {
|
||||
const { spawn } = await import("node:child_process");
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git", ["diff", "--no-color", "--find-renames", fromSha, toSha], {
|
||||
cwd: dir, windowsHide: true,
|
||||
});
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (c) => { out += c.toString(); });
|
||||
child.stderr.on("data", (c) => { err += c.toString(); });
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve(out);
|
||||
else reject(new Error(`git diff failed (${code}): ${err.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the OPEN pull request that a push belongs to.
|
||||
* Merged/closed pull requests are ignored: a push to the base branch after a
|
||||
* merge must not attach new review comments to the finished PR.
|
||||
*/
|
||||
async findPullRequestForSha(client, repo, sha, refName) {
|
||||
try {
|
||||
const list = await client.listPullRequests(repo.owner, repo.name, {
|
||||
state: "open", limit: 50,
|
||||
});
|
||||
const match = (list || []).find(
|
||||
(p) => p.head?.sha === sha || (refName && p.head?.ref === refName),
|
||||
);
|
||||
if (match) return match.number;
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
async upsertIssue({ client, repo, job, blocking, labels, title, body, createEnabled = true }) {
|
||||
// Look up any open issue previously opened by this service for this
|
||||
// repository + ref, so reruns update it instead of stacking new issues.
|
||||
const openIssues = await client.get(`/api/v1/repos/${repo.owner}/${repo.name}/issues`, {
|
||||
query: { state: "open", type: "issues", limit: 100 },
|
||||
}).catch(() => []);
|
||||
|
||||
const existing = (openIssues || []).find((i) => i.title === title)
|
||||
?? (openIssues || []).find(
|
||||
(i) => String(i.body || "").includes(SUMMARY_MARKER)
|
||||
&& String(i.title || "").includes(`${repoSlug(repo)} · ${job.ref_name}`),
|
||||
);
|
||||
|
||||
if (blocking.length === 0) {
|
||||
if (existing) {
|
||||
await client.createIssueComment(repo.owner, repo.name, existing.number,
|
||||
`已在 \`${String(job.to_sha).slice(0, 10)}\` 上复查通过,关闭该 Issue。`);
|
||||
await client.updateIssue(repo.owner, repo.name, existing.number, { state: "closed" });
|
||||
return existing.number;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!createEnabled) {
|
||||
// Issue creation is disabled for this repository; leave any existing
|
||||
// issue untouched rather than opening a new one.
|
||||
return existing?.number ?? null;
|
||||
}
|
||||
|
||||
// Gitea's issue API expects label IDs, so resolve names first.
|
||||
const labelIds = labels.length
|
||||
? await client.ensureLabels(repo.owner, repo.name, labels)
|
||||
: [];
|
||||
|
||||
if (existing) {
|
||||
await client.updateIssue(repo.owner, repo.name, existing.number, { body, title });
|
||||
if (labelIds.length) {
|
||||
await client.put(`/api/v1/repos/${repo.owner}/${repo.name}/issues/${existing.number}/labels`,
|
||||
{ labels: labelIds }).catch(() => {});
|
||||
}
|
||||
await client.createIssueComment(repo.owner, repo.name, existing.number,
|
||||
`已用 \`${String(job.to_sha).slice(0, 10)}\` 的最新审查结果更新该 Issue。`);
|
||||
return existing.number;
|
||||
}
|
||||
|
||||
const created = await client.createIssue(repo.owner, repo.name, {
|
||||
title, body, labels: labelIds.length ? labelIds : undefined,
|
||||
});
|
||||
return created?.number ?? null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 (blocking.length > 0) {
|
||||
appendLog(`auto-merge skipped: ${blocking.length} blocking finding(s)`);
|
||||
return false;
|
||||
}
|
||||
let pr;
|
||||
try {
|
||||
pr = await client.getPullRequest(repo.owner, repo.name, prNumber);
|
||||
} catch (err) {
|
||||
appendLog(`auto-merge skipped: cannot load PR: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
if (!pr || pr.merged) return false;
|
||||
if (pr.state !== "open") {
|
||||
appendLog("auto-merge skipped: PR is not open");
|
||||
return false;
|
||||
}
|
||||
if (pr.head?.sha && pr.head.sha !== toSha) {
|
||||
appendLog(`auto-merge skipped: PR head moved to ${String(pr.head.sha).slice(0, 10)}`);
|
||||
return false;
|
||||
}
|
||||
if (pr.mergeable === false) {
|
||||
appendLog("auto-merge skipped: PR is not mergeable");
|
||||
return false;
|
||||
}
|
||||
if (repo.auto_merge_mode === "immediate" && pr.mergeable === undefined) {
|
||||
appendLog("auto-merge skipped: mergeability unknown");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.mergePullRequest(repo.owner, repo.name, prNumber, {
|
||||
style: repo.merge_method || "squash",
|
||||
title: pr.title,
|
||||
deleteBranch: Boolean(repo.delete_branch),
|
||||
headCommitId: toSha,
|
||||
mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed",
|
||||
});
|
||||
appendLog("auto-merge requested");
|
||||
return true;
|
||||
} catch (err) {
|
||||
appendLog(`auto-merge failed: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async failJob(job, err) {
|
||||
const message = err instanceof SkipJob
|
||||
? `skipped: ${err.reason}`
|
||||
: `${err.name || "Error"}: ${err.message}`;
|
||||
this.log(`job #${job.id} ${message}`);
|
||||
updateJob(this.db, job.id, {
|
||||
status: err instanceof SkipJob ? "skipped" : "failed",
|
||||
phase: err instanceof SkipJob ? "skipped" : "failed",
|
||||
error: message,
|
||||
});
|
||||
if (!(err instanceof SkipJob) && job.pr_number) {
|
||||
try {
|
||||
const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(job.repo_id);
|
||||
if (repo) {
|
||||
const client = this.clientFor(repo);
|
||||
await client.createCommitStatus(repo.owner, repo.name, job.to_sha, {
|
||||
state: "error",
|
||||
context: STATUS_CONTEXT,
|
||||
description: "review failed to run",
|
||||
});
|
||||
await client.createIssueComment(repo.owner, repo.name, job.pr_number,
|
||||
`${SUMMARY_MARKER}\n代码审查执行失败:\n\n\`\`\`\n${err.message}\n\`\`\`\n\n<sub>job #${job.id}</sub>`);
|
||||
}
|
||||
} catch (postErr) {
|
||||
this.log(`failed to report job error: ${postErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWorkspaceFile(dir, name, content) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, name), content, "utf8");
|
||||
}
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
/** gitea-codereview HTTP server: webhooks, REST API, and admin UI. */
|
||||
import { createServer } from "node:http";
|
||||
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
allSettings, deleteRepository, enqueueJob, findJobBySha, findRepository,
|
||||
getJob, getSetting, jobStats, listJobs, listRepositories, openDatabase,
|
||||
recordDelivery, pruneDeliveries, setSetting, upsertRepository, getRepository,
|
||||
} from "./lib/db.js";
|
||||
import { GiteaClient } from "./lib/gitea.js";
|
||||
import { OcrRunner } from "./lib/ocr.js";
|
||||
import { JobQueue } from "./lib/queue.js";
|
||||
import { ReviewEngine, SkipJob, branchMatches } from "./lib/review.js";
|
||||
|
||||
const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||
const ROOT_DIR = resolve(APP_DIR, "..");
|
||||
const STATIC_DIR = join(APP_DIR, "static");
|
||||
|
||||
const SECRET_SETTING_KEYS = new Set(["giteaToken", "llmToken", "adminToken", "webhookSecret"]);
|
||||
|
||||
function readConfig() {
|
||||
const dataDir = process.env.CR_DATA_DIR || join(ROOT_DIR, "data");
|
||||
return {
|
||||
dataDir,
|
||||
dbPath: process.env.CR_DB_PATH || join(dataDir, "codereview.db"),
|
||||
port: Number(process.env.CR_PORT || 8090),
|
||||
host: process.env.CR_HOST || "0.0.0.0",
|
||||
ocrCommand: process.env.CR_OCR_COMMAND || "ocr",
|
||||
reviewTimeoutMs: Number(process.env.CR_REVIEW_TIMEOUT_MS || 45 * 60 * 1000),
|
||||
httpTimeoutMs: Number(process.env.CR_HTTP_TIMEOUT_MS || 60000),
|
||||
defaultConcurrency: Number(process.env.CR_CONCURRENCY || 4),
|
||||
maxTokensBudget: Number(process.env.CR_MAX_TOKENS_BUDGET || 0),
|
||||
pollMs: Number(process.env.CR_POLL_MS || 3000),
|
||||
maxAttempts: Number(process.env.CR_MAX_ATTEMPTS || 2),
|
||||
// Bootstrap defaults; persisted settings win once set through the UI.
|
||||
giteaUrl: process.env.CR_GITEA_URL || "http://127.0.0.1:80",
|
||||
giteaToken: process.env.CR_GITEA_TOKEN || "",
|
||||
webhookSecret: process.env.CR_WEBHOOK_SECRET || "",
|
||||
adminToken: process.env.CR_ADMIN_TOKEN || "",
|
||||
llmUrl: process.env.CR_LLM_URL || process.env.OCR_LLM_URL || "",
|
||||
llmToken: process.env.CR_LLM_TOKEN || process.env.OCR_LLM_TOKEN || "",
|
||||
llmModel: process.env.CR_LLM_MODEL || process.env.OCR_LLM_MODEL || "",
|
||||
llmProtocol: process.env.CR_LLM_PROTOCOL || process.env.OCR_LLM_PROTOCOL || "",
|
||||
llmAuthHeader: process.env.CR_LLM_AUTH_HEADER || "",
|
||||
llmExtraHeaders: process.env.CR_LLM_EXTRA_HEADERS || "",
|
||||
llmTimeoutSeconds: Number(process.env.CR_LLM_TIMEOUT || 180),
|
||||
rulePath: process.env.CR_RULE_PATH || "",
|
||||
};
|
||||
}
|
||||
|
||||
const CONFIG = readConfig();
|
||||
const db = openDatabase(CONFIG.dbPath);
|
||||
const logger = {
|
||||
info: (m) => console.log(`[${new Date().toISOString()}] ${m}`),
|
||||
warn: (m) => console.warn(`[${new Date().toISOString()}] WARN ${m}`),
|
||||
error: (m) => console.error(`[${new Date().toISOString()}] ERROR ${m}`),
|
||||
};
|
||||
|
||||
// Persisted settings override environment bootstrap values.
|
||||
function effectiveConfig() {
|
||||
const saved = allSettings(db);
|
||||
return {
|
||||
...CONFIG,
|
||||
giteaUrl: saved.giteaUrl || CONFIG.giteaUrl,
|
||||
giteaToken: saved.giteaToken || CONFIG.giteaToken,
|
||||
webhookSecret: saved.webhookSecret ?? CONFIG.webhookSecret,
|
||||
adminToken: saved.adminToken ?? CONFIG.adminToken,
|
||||
llmUrl: saved.llmUrl || CONFIG.llmUrl,
|
||||
llmToken: saved.llmToken || CONFIG.llmToken,
|
||||
llmModel: saved.llmModel || CONFIG.llmModel,
|
||||
llmProtocol: saved.llmProtocol || CONFIG.llmProtocol,
|
||||
llmAuthHeader: saved.llmAuthHeader || CONFIG.llmAuthHeader,
|
||||
llmExtraHeaders: saved.llmExtraHeaders || CONFIG.llmExtraHeaders,
|
||||
rulePath: saved.rulePath || CONFIG.rulePath,
|
||||
};
|
||||
}
|
||||
|
||||
const engine = new ReviewEngine({ db, config: effectiveConfig(), logger });
|
||||
const queue = new JobQueue({
|
||||
db, engine, logger,
|
||||
pollMs: CONFIG.pollMs,
|
||||
maxAttempts: CONFIG.maxAttempts,
|
||||
});
|
||||
|
||||
// The engine reads config at call time through a getter so UI changes apply
|
||||
// without a restart.
|
||||
Object.defineProperty(engine, "config", {
|
||||
get: effectiveConfig,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
/* ---------------------------------- utils --------------------------------- */
|
||||
|
||||
function json(res, status, payload) {
|
||||
const body = JSON.stringify(payload, null, 2);
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function text(res, status, body, type = "text/plain; charset=utf-8") {
|
||||
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
async function readBody(req, limit = 5 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > limit) throw new Error("request body too large");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function verifySignature(secret, rawBody, signature) {
|
||||
if (!secret) return true;
|
||||
if (!signature) return false;
|
||||
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(String(signature).trim(), "utf8");
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function authorized(req, cfg) {
|
||||
if (!cfg.adminToken) return true;
|
||||
const header = req.headers.authorization || "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : "";
|
||||
if (!token) return false;
|
||||
const a = Buffer.from(token);
|
||||
const b = Buffer.from(cfg.adminToken);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/* -------------------------------- webhooks -------------------------------- */
|
||||
|
||||
function refNameFromPayload(payload) {
|
||||
const ref = payload.ref || "";
|
||||
return ref.replace(/^refs\/heads\//, "");
|
||||
}
|
||||
|
||||
async function handlePush(payload, cfg) {
|
||||
const owner = payload.repository?.owner?.username || payload.repository?.owner?.login;
|
||||
const name = payload.repository?.name;
|
||||
if (!owner || !name) return { queued: 0 };
|
||||
const repo = findRepository(db, owner, name);
|
||||
if (!repo || !repo.enabled) return { queued: 0, reason: "repository not configured" };
|
||||
|
||||
const refName = refNameFromPayload(payload);
|
||||
if (!refName || payload.deleted) return { queued: 0, reason: "branch deletion or empty ref" };
|
||||
if (!branchMatches(refName, repo.branch_patterns)) {
|
||||
return { queued: 0, reason: `branch ${refName} does not match patterns` };
|
||||
}
|
||||
|
||||
const toSha = payload.after || payload.head_commit?.id;
|
||||
if (!toSha) return { queued: 0, reason: "no head commit in payload" };
|
||||
|
||||
const scoped = repo.review_scope === "pr";
|
||||
const pr = scoped ? null : await findOpenPullRequestForRef(cfg, repo, refName, toSha);
|
||||
if (scoped && !pr) {
|
||||
return { queued: 0, reason: "review_scope=pr and no open pull request" };
|
||||
}
|
||||
|
||||
if (findJobBySha(db, repo.id, toSha)) {
|
||||
return { queued: 0, reason: `commit ${toSha.slice(0, 10)} already queued or running` };
|
||||
}
|
||||
|
||||
const before = payload.before && !/^0+$/.test(payload.before) ? payload.before : null;
|
||||
const jobId = enqueueJob(db, {
|
||||
repoId: repo.id,
|
||||
trigger: "push",
|
||||
refName,
|
||||
baseRef: pr?.base?.ref ?? repo.base_branch,
|
||||
fromSha: before,
|
||||
toSha,
|
||||
prNumber: pr?.number ?? null,
|
||||
});
|
||||
logger.info(`queued job #${jobId} for ${owner}/${name} ${refName}@${toSha.slice(0, 10)}`);
|
||||
return { queued: 1, jobId };
|
||||
}
|
||||
|
||||
async function handlePullRequest(payload, cfg) {
|
||||
const action = payload.action;
|
||||
if (!["opened", "synchronize", "reopened", "ready_for_review"].includes(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" };
|
||||
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" };
|
||||
if (!branchMatches(pr.head?.ref, repo.branch_patterns)) {
|
||||
return { queued: 0, reason: `head branch ${pr.head?.ref} does not match patterns` };
|
||||
}
|
||||
const toSha = pr.head?.sha;
|
||||
if (!toSha) return { queued: 0, reason: "no head sha" };
|
||||
if (findJobBySha(db, repo.id, toSha)) {
|
||||
return { queued: 0, reason: `commit ${toSha.slice(0, 10)} already queued or running` };
|
||||
}
|
||||
const jobId = enqueueJob(db, {
|
||||
repoId: repo.id,
|
||||
trigger: `pull_request.${action}`,
|
||||
refName: pr.head.ref,
|
||||
baseRef: pr.base?.ref ?? repo.base_branch,
|
||||
fromSha: pr.base?.sha ?? null,
|
||||
toSha,
|
||||
prNumber: pr.number,
|
||||
});
|
||||
logger.info(`queued job #${jobId} for PR #${pr.number} (${owner}/${name})`);
|
||||
return { queued: 1, jobId };
|
||||
}
|
||||
|
||||
async function findOpenPullRequestForRef(cfg, repo, refName, sha) {
|
||||
try {
|
||||
const client = new GiteaClient({
|
||||
baseUrl: cfg.giteaUrl,
|
||||
token: repo.gitea_token || cfg.giteaToken,
|
||||
});
|
||||
const list = await client.listPullRequests(repo.owner, repo.name, { state: "open", limit: 50 });
|
||||
return (list || []).find((p) => p.head?.ref === refName || p.head?.sha === sha) ?? null;
|
||||
} catch (err) {
|
||||
logger.warn(`cannot look up pull request for ${refName}: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------- routes -------------------------------- */
|
||||
|
||||
async function handleApi(req, res, url, cfg) {
|
||||
const path = url.pathname.replace(/^\/api/, "");
|
||||
|
||||
if (path === "/health") {
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
queue: jobStats(db),
|
||||
currentJob: queue.currentJobId,
|
||||
giteaUrl: cfg.giteaUrl,
|
||||
llmModel: cfg.llmModel || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!authorized(req, cfg)) return json(res, 401, { error: "unauthorized" });
|
||||
|
||||
if (path === "/settings" && req.method === "GET") {
|
||||
const saved = allSettings(db);
|
||||
const out = {
|
||||
giteaUrl: cfg.giteaUrl,
|
||||
webhookSecretSet: Boolean(cfg.webhookSecret),
|
||||
adminTokenSet: Boolean(cfg.adminToken),
|
||||
llmUrl: cfg.llmUrl,
|
||||
llmModel: cfg.llmModel,
|
||||
llmProtocol: cfg.llmProtocol,
|
||||
rulePath: cfg.rulePath,
|
||||
giteaTokenSet: Boolean(cfg.giteaToken),
|
||||
llmTokenSet: Boolean(cfg.llmToken),
|
||||
raw: Object.fromEntries(Object.entries(saved).filter(([k]) => !SECRET_SETTING_KEYS.has(k))),
|
||||
};
|
||||
return json(res, 200, out);
|
||||
}
|
||||
|
||||
if (path === "/settings" && req.method === "PUT") {
|
||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||
const allowed = [
|
||||
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken",
|
||||
"llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath",
|
||||
];
|
||||
for (const key of allowed) {
|
||||
if (body[key] !== undefined) setSetting(db, key, body[key]);
|
||||
}
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (path === "/repos" && req.method === "GET") {
|
||||
return json(res, 200, listRepositories(db));
|
||||
}
|
||||
|
||||
if (path === "/repos" && req.method === "POST") {
|
||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||
if (!body.owner || !body.name) return json(res, 400, { error: "owner and name are required" });
|
||||
const repo = upsertRepository(db, {
|
||||
owner: body.owner,
|
||||
name: body.name,
|
||||
enabled: body.enabled === undefined ? 1 : Number(Boolean(body.enabled)),
|
||||
base_branch: body.base_branch || "main",
|
||||
branch_patterns: body.branch_patterns || "*",
|
||||
review_scope: body.review_scope || "both",
|
||||
create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)),
|
||||
auto_merge: Number(Boolean(body.auto_merge)),
|
||||
});
|
||||
return json(res, 201, repo);
|
||||
}
|
||||
|
||||
const repoMatch = /^\/repos\/(\d+)$/.exec(path);
|
||||
if (repoMatch) {
|
||||
const id = Number(repoMatch[1]);
|
||||
const repo = getRepository(db, id);
|
||||
if (!repo) return json(res, 404, { error: "repository not found" });
|
||||
if (req.method === "GET") return json(res, 200, repo);
|
||||
if (req.method === "PATCH") {
|
||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||
const updated = upsertRepository(db, { ...body, id });
|
||||
return json(res, 200, updated);
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
deleteRepository(db, id);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
const discoverMatch = /^\/repos\/(\d+)\/discover$/.exec(path);
|
||||
if (discoverMatch && req.method === "POST") {
|
||||
const repo = getRepository(db, Number(discoverMatch[1]));
|
||||
if (!repo) return json(res, 404, { error: "repository not found" });
|
||||
const client = new GiteaClient({
|
||||
baseUrl: cfg.giteaUrl,
|
||||
token: repo.gitea_token || cfg.giteaToken,
|
||||
});
|
||||
try {
|
||||
const info = await client.getRepo(repo.owner, repo.name);
|
||||
const branches = await client.listRepoBranches(repo.owner, repo.name);
|
||||
const updated = upsertRepository(db, {
|
||||
id: repo.id,
|
||||
base_branch: repo.base_branch || info.default_branch,
|
||||
});
|
||||
return json(res, 200, {
|
||||
repo: updated,
|
||||
default_branch: info.default_branch,
|
||||
has_issues: info.has_issues,
|
||||
has_pull_requests: info.has_pull_requests,
|
||||
branches: branches.map((b) => b.name),
|
||||
});
|
||||
} catch (err) {
|
||||
return json(res, 502, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (path === "/jobs" && req.method === "GET") {
|
||||
const repoId = url.searchParams.get("repo_id");
|
||||
const limit = Math.min(Number(url.searchParams.get("limit") || 50), 200);
|
||||
return json(res, 200, listJobs(db, { repoId: repoId ? Number(repoId) : undefined, limit }));
|
||||
}
|
||||
|
||||
const jobMatch = /^\/jobs\/(\d+)$/.exec(path);
|
||||
if (jobMatch && req.method === "GET") {
|
||||
const job = getJob(db, Number(jobMatch[1]));
|
||||
if (!job) return json(res, 404, { error: "job not found" });
|
||||
const repo = getRepository(db, job.repo_id);
|
||||
return json(res, 200, { ...job, repository: repo ? `${repo.owner}/${repo.name}` : null });
|
||||
}
|
||||
|
||||
const retryMatch = /^\/jobs\/(\d+)\/retry$/.exec(path);
|
||||
if (retryMatch && req.method === "POST") {
|
||||
const job = getJob(db, Number(retryMatch[1]));
|
||||
if (!job) return json(res, 404, { error: "job not found" });
|
||||
const newId = enqueueJob(db, {
|
||||
repoId: job.repo_id,
|
||||
trigger: `${job.trigger}+retry`,
|
||||
refName: job.ref_name,
|
||||
baseRef: job.base_ref,
|
||||
fromSha: job.from_sha,
|
||||
toSha: job.to_sha,
|
||||
prNumber: job.pr_number,
|
||||
});
|
||||
return json(res, 201, { jobId: newId });
|
||||
}
|
||||
|
||||
if (path === "/review" && req.method === "POST") {
|
||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||
const repo = body.repo_id ? getRepository(db, Number(body.repo_id))
|
||||
: findRepository(db, body.owner, body.name);
|
||||
if (!repo) return json(res, 404, { error: "repository not configured" });
|
||||
const toSha = body.sha;
|
||||
if (!toSha) return json(res, 400, { error: "sha is required" });
|
||||
const jobId = enqueueJob(db, {
|
||||
repoId: repo.id,
|
||||
trigger: "manual",
|
||||
refName: body.ref || repo.base_branch,
|
||||
baseRef: body.base_ref || repo.base_branch,
|
||||
fromSha: body.from_sha || null,
|
||||
toSha,
|
||||
prNumber: body.pr_number || null,
|
||||
});
|
||||
return json(res, 201, { jobId });
|
||||
}
|
||||
|
||||
if (path === "/selftest" && req.method === "POST") {
|
||||
const runner = new OcrRunner({
|
||||
command: cfg.ocrCommand,
|
||||
llm: {
|
||||
url: cfg.llmUrl, token: cfg.llmToken, model: cfg.llmModel,
|
||||
protocol: cfg.llmProtocol, authHeader: cfg.llmAuthHeader,
|
||||
extraHeaders: cfg.llmExtraHeaders, timeoutSeconds: cfg.llmTimeoutSeconds,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runner.selfTest();
|
||||
return json(res, 200, result);
|
||||
} catch (err) {
|
||||
return json(res, 502, { error: err.message, stderr: err.stderr ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
if (path === "/gitea/test" && req.method === "POST") {
|
||||
try {
|
||||
const client = new GiteaClient({
|
||||
baseUrl: cfg.giteaUrl,
|
||||
token: cfg.giteaToken,
|
||||
});
|
||||
const version = await client.getVersion();
|
||||
let user = null;
|
||||
try { user = await client.getCurrentUser(); } catch { /* token may be missing */ }
|
||||
return json(res, 200, { version: version?.version ?? null, user: user?.login ?? null });
|
||||
} catch (err) {
|
||||
return json(res, 502, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return json(res, 404, { error: "not found" });
|
||||
}
|
||||
|
||||
async function serveStatic(res, path) {
|
||||
const rel = path === "/" ? "/index.html" : path;
|
||||
const full = join(STATIC_DIR, rel);
|
||||
if (!resolve(full).startsWith(STATIC_DIR)) return text(res, 403, "forbidden");
|
||||
if (!existsSync(full)) {
|
||||
return text(res, 404, "not found");
|
||||
}
|
||||
const info = await stat(full);
|
||||
if (!info.isFile()) return text(res, 404, "not found");
|
||||
const types = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".json": "application/json; charset=utf-8",
|
||||
};
|
||||
return text(res, 200, await readFile(full), types[extname(full)] || "application/octet-stream");
|
||||
}
|
||||
|
||||
/* ---------------------------------- server -------------------------------- */
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||||
try {
|
||||
if (url.pathname === "/webhook/gitea" && req.method === "POST") {
|
||||
const cfg = effectiveConfig();
|
||||
const raw = await readBody(req);
|
||||
const signature = req.headers["x-gitea-signature"];
|
||||
if (!verifySignature(cfg.webhookSecret, raw, signature)) {
|
||||
logger.warn("webhook rejected: bad signature");
|
||||
return json(res, 401, { error: "invalid signature" });
|
||||
}
|
||||
const deliveryId = req.headers["x-gitea-delivery"] || randomUUID();
|
||||
if (!recordDelivery(db, deliveryId)) {
|
||||
return json(res, 200, { ok: true, duplicate: true });
|
||||
}
|
||||
pruneDeliveries(db);
|
||||
|
||||
const event = req.headers["x-gitea-event"] || "unknown";
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(raw.toString("utf8") || "{}");
|
||||
} catch {
|
||||
return json(res, 400, { error: "invalid JSON payload" });
|
||||
}
|
||||
|
||||
let result = { queued: 0 };
|
||||
if (event === "push") result = await handlePush(payload, cfg);
|
||||
else if (event === "pull_request") result = await handlePullRequest(payload, cfg);
|
||||
else result = { queued: 0, reason: `event ${event} ignored` };
|
||||
|
||||
logger.info(`webhook ${event}: ${JSON.stringify(result)}`);
|
||||
return json(res, 202, { ok: true, event, ...result });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api")) {
|
||||
return await handleApi(req, res, url, effectiveConfig());
|
||||
}
|
||||
|
||||
if (req.method === "GET") return await serveStatic(res, url.pathname);
|
||||
return text(res, 405, "method not allowed");
|
||||
} catch (err) {
|
||||
logger.error(`${req.method} ${url.pathname} -> ${err.stack || err.message}`);
|
||||
return json(res, 500, { error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(CONFIG.port, CONFIG.host, () => {
|
||||
logger.info(`gitea-codereview listening on http://${CONFIG.host}:${CONFIG.port}`);
|
||||
logger.info(`database: ${CONFIG.dbPath}`);
|
||||
logger.info(`webhook endpoint: /webhook/gitea`);
|
||||
queue.start();
|
||||
});
|
||||
|
||||
function shutdown(signal) {
|
||||
logger.info(`${signal} received, shutting down`);
|
||||
queue.stop();
|
||||
server.close(() => {
|
||||
try { db.close(); } catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => process.exit(0), 15000).unref();
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
export { server, db, engine, queue };
|
||||
@@ -0,0 +1,426 @@
|
||||
"use strict";
|
||||
|
||||
const state = { repos: [], jobs: [], token: "" };
|
||||
|
||||
/* ---------------------------------- auth ---------------------------------- */
|
||||
|
||||
const TOKEN_KEY = "cr-admin-token";
|
||||
|
||||
function token() {
|
||||
return state.token || localStorage.getItem(TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
function showGate(message) {
|
||||
const gate = document.getElementById("token-gate");
|
||||
const err = document.getElementById("token-error");
|
||||
gate.classList.remove("hidden");
|
||||
if (message) {
|
||||
err.textContent = message;
|
||||
err.classList.remove("hidden");
|
||||
} else {
|
||||
err.classList.add("hidden");
|
||||
}
|
||||
document.getElementById("token-form").elements.token.focus();
|
||||
}
|
||||
|
||||
function hideGate() {
|
||||
document.getElementById("token-gate").classList.add("hidden");
|
||||
}
|
||||
|
||||
function setBanner(message, kind) {
|
||||
const el = document.getElementById("banner");
|
||||
if (!message) {
|
||||
el.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
el.textContent = message;
|
||||
el.className = `banner${kind === "ok" ? " ok" : ""}`;
|
||||
}
|
||||
|
||||
document.getElementById("token-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const value = ev.target.elements.token.value.trim();
|
||||
if (!value) return;
|
||||
state.token = value;
|
||||
try {
|
||||
await api("/repos");
|
||||
localStorage.setItem(TOKEN_KEY, value);
|
||||
hideGate();
|
||||
document.getElementById("sign-out").classList.remove("hidden");
|
||||
setBanner("");
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
state.token = "";
|
||||
showGate(`Token 无效:${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("sign-out").addEventListener("click", () => {
|
||||
state.token = "";
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
document.getElementById("sign-out").classList.add("hidden");
|
||||
showGate("");
|
||||
});
|
||||
|
||||
/* ---------------------------------- api ----------------------------------- */
|
||||
|
||||
async function api(path, { method = "GET", body } = {}) {
|
||||
const headers = { Accept: "application/json" };
|
||||
const t = token();
|
||||
if (t) headers.Authorization = `Bearer ${t}`;
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const textBody = await res.text();
|
||||
let parsed = null;
|
||||
try { parsed = textBody ? JSON.parse(textBody) : null; } catch { parsed = textBody; }
|
||||
if (!res.ok) {
|
||||
const error = new Error(parsed?.error || `HTTP ${res.status}`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function show(selector, content) {
|
||||
const el = document.querySelector(selector);
|
||||
el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hide(selector) { document.querySelector(selector).classList.add("hidden"); }
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "").replace(/[&<>"]/g, (c) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """ }[c]
|
||||
));
|
||||
}
|
||||
|
||||
function fmtTime(value) {
|
||||
if (!value) return "";
|
||||
return String(value).replace("T", " ").replace("Z", "");
|
||||
}
|
||||
|
||||
/* ---------------------------------- tabs ---------------------------------- */
|
||||
|
||||
for (const tab of document.querySelectorAll(".tab")) {
|
||||
tab.addEventListener("click", () => {
|
||||
for (const t of document.querySelectorAll(".tab")) t.classList.toggle("active", t === tab);
|
||||
for (const p of document.querySelectorAll(".panel")) {
|
||||
p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`);
|
||||
}
|
||||
if (tab.dataset.tab === "jobs") loadJobs().catch(handleError);
|
||||
if (tab.dataset.tab === "settings") loadSettings().catch(handleError);
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------- health --------------------------------- */
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const h = await api("/health");
|
||||
const q = h.queue || {};
|
||||
document.getElementById("status").textContent =
|
||||
`队列 运行${q.running || 0} / 等待${q.queued || 0} · 成功${q.succeeded || 0} · 失败${q.failed || 0}` +
|
||||
(h.llmModel ? ` · ${h.llmModel}` : "");
|
||||
} catch (err) {
|
||||
document.getElementById("status").textContent = `服务异常:${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(err) {
|
||||
if (err.status === 401) {
|
||||
document.getElementById("sign-out").classList.add("hidden");
|
||||
showGate("Token 无效或已过期,请重新输入。");
|
||||
return;
|
||||
}
|
||||
setBanner(err.message);
|
||||
}
|
||||
|
||||
/* ---------------------------------- repos --------------------------------- */
|
||||
|
||||
const REPO_FIELDS = [
|
||||
"id", "owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
|
||||
"gitea_token", "llm_model", "llm_token", "background_template", "excludes",
|
||||
"publish_mode", "create_issue", "issue_labels", "block_severity", "block_categories",
|
||||
"fail_on_findings", "auto_merge", "auto_merge_mode", "merge_method", "delete_branch",
|
||||
"max_comments", "concurrency",
|
||||
];
|
||||
|
||||
async function loadRepos() {
|
||||
state.repos = await api("/repos");
|
||||
const tbody = document.querySelector("#repos-table tbody");
|
||||
if (state.repos.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty">还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = state.repos.map((r) => `
|
||||
<tr>
|
||||
<td><strong>${esc(r.owner)}/${esc(r.name)}</strong><br><span class="tag muted">${esc(r.base_branch)}</span></td>
|
||||
<td><code>${esc(r.branch_patterns)}</code></td>
|
||||
<td>${esc(scopeLabel(r.review_scope))}</td>
|
||||
<td>${r.enabled ? '<span class="tag ok">启用</span>' : '<span class="tag muted">停用</span>'}</td>
|
||||
<td>${r.auto_merge ? `<span class="tag warn">${esc(r.merge_method)}</span>` : '<span class="tag muted">否</span>'}</td>
|
||||
<td><code>${esc(r.block_severity || "—")}</code></td>
|
||||
<td>
|
||||
<button data-edit="${r.id}">编辑</button>
|
||||
<button data-run="${r.id}">立即审查</button>
|
||||
<button class="danger" data-del="${r.id}">删除</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
function scopeLabel(scope) {
|
||||
return { both: "Push + PR", pr: "仅 PR", push: "仅 Push" }[scope] || scope;
|
||||
}
|
||||
|
||||
document.querySelector("#repos-table").addEventListener("click", async (ev) => {
|
||||
const target = ev.target.closest("button");
|
||||
if (!target) return;
|
||||
try {
|
||||
if (target.dataset.edit) openRepo(Number(target.dataset.edit));
|
||||
if (target.dataset.del) {
|
||||
if (!confirm("删除该仓库配置?历史任务会一并删除。")) return;
|
||||
await api(`/repos/${target.dataset.del}`, { method: "DELETE" });
|
||||
await loadRepos();
|
||||
}
|
||||
if (target.dataset.run) openManual(Number(target.dataset.run));
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("add-repo").addEventListener("click", () => openRepo(null));
|
||||
document.getElementById("refresh-jobs").addEventListener("click", () => loadJobs().catch(handleError));
|
||||
|
||||
/* ------------------------------- repo dialog ------------------------------ */
|
||||
|
||||
const modal = document.getElementById("modal");
|
||||
const form = document.getElementById("repo-form");
|
||||
|
||||
function openRepo(id) {
|
||||
form.reset();
|
||||
hide("#repo-result");
|
||||
const repo = id ? state.repos.find((r) => r.id === id) : null;
|
||||
document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库";
|
||||
for (const field of REPO_FIELDS) {
|
||||
const input = form.elements[field];
|
||||
if (!input) continue;
|
||||
if (!repo) {
|
||||
// Defaults for a brand-new repository.
|
||||
if (field === "enabled" || field === "create_issue") input.checked = true;
|
||||
continue;
|
||||
}
|
||||
if (input.type === "checkbox") input.checked = Boolean(repo[field]);
|
||||
else if (input.type === "password") input.value = "";
|
||||
else input.value = repo[field] ?? "";
|
||||
}
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("modal-close").addEventListener("click", () => modal.classList.add("hidden"));
|
||||
modal.addEventListener("click", (ev) => { if (ev.target === modal) modal.classList.add("hidden"); });
|
||||
|
||||
form.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const data = {};
|
||||
for (const field of REPO_FIELDS) {
|
||||
const input = form.elements[field];
|
||||
if (!input) continue;
|
||||
if (input.type === "checkbox") data[field] = input.checked ? 1 : 0;
|
||||
else if (input.type === "password") { if (input.value) data[field] = input.value; }
|
||||
else if (input.value !== "") data[field] = input.value;
|
||||
}
|
||||
try {
|
||||
if (data.id) await api(`/repos/${data.id}`, { method: "PATCH", body: data });
|
||||
else await api("/repos", { method: "POST", body: data });
|
||||
modal.classList.add("hidden");
|
||||
setBanner("");
|
||||
await loadRepos();
|
||||
} catch (err) {
|
||||
if (err.status === 401) return handleError(err);
|
||||
show("#repo-result", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("discover").addEventListener("click", async () => {
|
||||
const id = form.elements.id.value;
|
||||
if (!id) return show("#repo-result", "请先保存仓库,再测试连接。");
|
||||
show("#repo-result", "测试中…");
|
||||
try {
|
||||
const info = await api(`/repos/${id}/discover`, { method: "POST" });
|
||||
show("#repo-result",
|
||||
`连接成功\n默认分支:${info.default_branch}\nIssue 功能:${info.has_issues}\nPR 功能:${info.has_pull_requests}\n分支:${info.branches.join(", ")}`);
|
||||
} catch (err) {
|
||||
if (err.status === 401) return handleError(err);
|
||||
show("#repo-result", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------ manual dialog ----------------------------- */
|
||||
|
||||
const manualModal = document.getElementById("manual-modal");
|
||||
const manualForm = document.getElementById("manual-form");
|
||||
|
||||
function openManual(repoId) {
|
||||
const repo = state.repos.find((r) => r.id === repoId);
|
||||
if (!repo) return;
|
||||
manualForm.reset();
|
||||
hide("#manual-result");
|
||||
manualForm.elements.repo_id.value = repo.id;
|
||||
manualForm.elements.ref.value = repo.base_branch || "main";
|
||||
manualForm.elements.base_ref.value = repo.base_branch || "main";
|
||||
document.getElementById("manual-repo").textContent = `${repo.owner}/${repo.name}`;
|
||||
manualModal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("manual-close").addEventListener("click", () => manualModal.classList.add("hidden"));
|
||||
manualModal.addEventListener("click", (ev) => { if (ev.target === manualModal) manualModal.classList.add("hidden"); });
|
||||
|
||||
manualForm.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const body = {
|
||||
repo_id: Number(manualForm.elements.repo_id.value),
|
||||
ref: manualForm.elements.ref.value.trim(),
|
||||
sha: manualForm.elements.sha.value.trim(),
|
||||
base_ref: manualForm.elements.base_ref.value.trim() || undefined,
|
||||
};
|
||||
const pr = manualForm.elements.pr_number.value.trim();
|
||||
if (pr) body.pr_number = Number(pr);
|
||||
try {
|
||||
const res = await api("/review", { method: "POST", body });
|
||||
manualModal.classList.add("hidden");
|
||||
setBanner(`已加入队列:任务 #${res.jobId}`, "ok");
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
if (err.status === 401) return handleError(err);
|
||||
show("#manual-result", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------- jobs ---------------------------------- */
|
||||
|
||||
const STATUS_TAG = {
|
||||
queued: "muted", running: "warn", succeeded: "ok", failed: "err", skipped: "muted",
|
||||
};
|
||||
|
||||
async function loadJobs() {
|
||||
state.jobs = await api("/jobs?limit=100");
|
||||
const tbody = document.querySelector("#jobs-table tbody");
|
||||
if (state.jobs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="empty">暂无任务。仓库收到 push 或 Pull Request 后会出现在这里。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = state.jobs.map((j) => `
|
||||
<tr>
|
||||
<td>${j.id}</td>
|
||||
<td>${esc(j.owner)}/${esc(j.name)}</td>
|
||||
<td><code>${esc(j.ref_name)}</code>${j.pr_number ? ` <span class="tag muted">PR #${j.pr_number}</span>` : ""}</td>
|
||||
<td><code>${esc(String(j.to_sha).slice(0, 10))}</code></td>
|
||||
<td><span class="tag ${STATUS_TAG[j.status] || "muted"}">${esc(j.status)}</span>${j.phase ? ` <span class="tag muted">${esc(j.phase)}</span>` : ""}</td>
|
||||
<td>${j.findings ?? 0}</td>
|
||||
<td>${j.blocking ? `<span class="tag err">${j.blocking}</span>` : "0"}</td>
|
||||
<td>${j.merged ? '<span class="tag ok">已合并</span>' : ""}</td>
|
||||
<td>${fmtTime(j.started_at || j.created_at)}</td>
|
||||
<td><button data-log="${j.id}">日志</button> <button data-retry="${j.id}">重跑</button></td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
document.querySelector("#jobs-table").addEventListener("click", async (ev) => {
|
||||
const target = ev.target.closest("button");
|
||||
if (!target) return;
|
||||
try {
|
||||
if (target.dataset.log) {
|
||||
const job = await api(`/jobs/${target.dataset.log}`);
|
||||
show("#job-log", job.log || "(无日志)");
|
||||
}
|
||||
if (target.dataset.retry) {
|
||||
await api(`/jobs/${target.dataset.retry}/retry`, { method: "POST" });
|
||||
await loadJobs();
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/* -------------------------------- settings -------------------------------- */
|
||||
|
||||
const SETTINGS_FIELDS = [
|
||||
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken",
|
||||
"llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath",
|
||||
];
|
||||
|
||||
async function loadSettings() {
|
||||
const settingsForm = document.getElementById("settings-form");
|
||||
const s = await api("/settings");
|
||||
for (const field of SETTINGS_FIELDS) {
|
||||
const input = settingsForm.elements[field];
|
||||
if (!input) continue;
|
||||
if (input.type === "password") input.value = "";
|
||||
else input.value = s[field] ?? "";
|
||||
}
|
||||
document.getElementById("hook-url").textContent = `${location.origin}/webhook/gitea`;
|
||||
}
|
||||
|
||||
document.getElementById("settings-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const settingsForm = ev.target;
|
||||
const data = {};
|
||||
for (const field of SETTINGS_FIELDS) {
|
||||
const input = settingsForm.elements[field];
|
||||
if (input && input.value) data[field] = input.value;
|
||||
}
|
||||
try {
|
||||
await api("/settings", { method: "PUT", body: data });
|
||||
if (data.adminToken) {
|
||||
state.token = data.adminToken;
|
||||
localStorage.setItem(TOKEN_KEY, data.adminToken);
|
||||
}
|
||||
for (const field of SETTINGS_FIELDS) {
|
||||
const input = settingsForm.elements[field];
|
||||
if (input && input.type === "password") input.value = "";
|
||||
}
|
||||
show("#settings-result", "已保存。");
|
||||
await loadHealth();
|
||||
} catch (err) {
|
||||
if (err.status === 401) return handleError(err);
|
||||
show("#settings-result", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("test-gitea").addEventListener("click", async () => {
|
||||
show("#settings-result", "测试中…");
|
||||
try { show("#settings-result", await api("/gitea/test", { method: "POST" })); }
|
||||
catch (err) { show("#settings-result", err.message); }
|
||||
});
|
||||
|
||||
document.getElementById("test-llm").addEventListener("click", async () => {
|
||||
show("#settings-result", "测试中,请稍候…");
|
||||
try { show("#settings-result", await api("/selftest", { method: "POST" })); }
|
||||
catch (err) { show("#settings-result", err.message); }
|
||||
});
|
||||
|
||||
/* --------------------------------- startup -------------------------------- */
|
||||
|
||||
async function refreshAll() {
|
||||
await loadRepos();
|
||||
await loadHealth();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
await loadHealth();
|
||||
if (!token()) {
|
||||
showGate("");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await refreshAll();
|
||||
document.getElementById("sign-out").classList.remove("hidden");
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
setInterval(loadHealth, 10000);
|
||||
@@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Gitea 代码审查</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='14' font-size='14'%3E%F0%9F%94%8D%3C/text%3E%3C/svg%3E" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Gitea 代码审查</h1>
|
||||
<div class="right"><div class="status" id="status">加载中…</div><button class="linkish hidden" id="sign-out">清除访问 Token</button></div>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<button class="tab active" data-tab="repos">仓库</button>
|
||||
<button class="tab" data-tab="jobs">任务</button>
|
||||
<button class="tab" data-tab="settings">设置</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div id="banner" class="banner hidden"></div>
|
||||
|
||||
<section id="tab-repos" class="panel active">
|
||||
<div class="row">
|
||||
<h2>已配置仓库</h2>
|
||||
<button id="add-repo">新增仓库</button>
|
||||
</div>
|
||||
<table id="repos-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>仓库</th><th>分支过滤</th><th>范围</th><th>启用</th>
|
||||
<th>自动合并</th><th>阻断级别</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="tab-jobs" class="panel">
|
||||
<div class="row">
|
||||
<h2>审查任务</h2>
|
||||
<button id="refresh-jobs">刷新</button>
|
||||
</div>
|
||||
<table id="jobs-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>仓库</th><th>分支</th><th>提交</th><th>状态</th>
|
||||
<th>意见</th><th>阻断</th><th>合并</th><th>开始</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<pre id="job-log" class="log hidden"></pre>
|
||||
</section>
|
||||
|
||||
<section id="tab-settings" class="panel">
|
||||
<h2>全局设置</h2>
|
||||
<form id="settings-form">
|
||||
<fieldset>
|
||||
<legend>Gitea</legend>
|
||||
<label>API 地址 <input name="giteaUrl" placeholder="http://127.0.0.1:80" /></label>
|
||||
<label>管理员 Token <input name="giteaToken" type="password" placeholder="留空表示不修改" /></label>
|
||||
<label>Webhook 密钥 <input name="webhookSecret" type="password" placeholder="留空表示不修改" /></label>
|
||||
<label>后台访问 Token <input name="adminToken" type="password" placeholder="留空表示不修改" /></label>
|
||||
<p class="hint">Webhook 地址:<code id="hook-url"></code>(在仓库 Settings → Webhooks 中添加,密钥填上面这一项)</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>LLM</legend>
|
||||
<label>接口地址 <input name="llmUrl" placeholder="https://api.openai.com/v1" /></label>
|
||||
<label>API Key <input name="llmToken" type="password" placeholder="留空表示不修改" /></label>
|
||||
<label>模型 <input name="llmModel" placeholder="gpt-5.5" /></label>
|
||||
<label>协议 <input name="llmProtocol" placeholder="openai / anthropic" /></label>
|
||||
<label>自定义认证头 <input name="llmAuthHeader" placeholder="留空使用默认" /></label>
|
||||
<label>附加请求头 <input name="llmExtraHeaders" placeholder="K=V,K=V" /></label>
|
||||
<label>审查规则文件 <input name="rulePath" placeholder="/etc/gitea-codereview/rule.json" /></label>
|
||||
</fieldset>
|
||||
<div class="actions">
|
||||
<button type="submit">保存</button>
|
||||
<button type="button" id="test-gitea">测试 Gitea</button>
|
||||
<button type="button" id="test-llm">测试 LLM</button>
|
||||
</div>
|
||||
<pre id="settings-result" class="log hidden"></pre>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="token-gate" class="modal hidden">
|
||||
<div class="modal-body narrow">
|
||||
<h2>需要访问 Token</h2>
|
||||
<p class="hint">该服务设置了 <code>CR_ADMIN_TOKEN</code>,请输入后才能管理仓库与任务。</p>
|
||||
<form id="token-form">
|
||||
<label>访问 Token
|
||||
<input name="token" type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">进入</button>
|
||||
</div>
|
||||
<pre id="token-error" class="log hidden"></pre>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="manual-modal" class="modal hidden">
|
||||
<div class="modal-body narrow">
|
||||
<h2>立即审查</h2>
|
||||
<form id="manual-form">
|
||||
<input type="hidden" name="repo_id" />
|
||||
<p class="hint" id="manual-repo"></p>
|
||||
<label>分支 <input name="ref" placeholder="main" required /></label>
|
||||
<label>提交 SHA <input name="sha" placeholder="40 位 commit hash" required /></label>
|
||||
<label>基准分支 <input name="base_ref" placeholder="main" /></label>
|
||||
<label>关联 Pull Request 编号(可选) <input name="pr_number" type="number" min="1" /></label>
|
||||
<div class="actions">
|
||||
<button type="submit">加入队列</button>
|
||||
<button type="button" id="manual-close">取消</button>
|
||||
</div>
|
||||
<pre id="manual-result" class="log hidden"></pre>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal" class="modal hidden">
|
||||
<div class="modal-body">
|
||||
<h2 id="modal-title">仓库配置</h2>
|
||||
<form id="repo-form">
|
||||
<input type="hidden" name="id" />
|
||||
<div class="grid">
|
||||
<label>所有者 <input name="owner" required /></label>
|
||||
<label>仓库名 <input name="name" required /></label>
|
||||
<label>目标分支 <input name="base_branch" placeholder="main" /></label>
|
||||
<label>监听分支模式 <input name="branch_patterns" placeholder="*, release/*" /></label>
|
||||
<label>审查范围
|
||||
<select name="review_scope">
|
||||
<option value="both">Push 与 PR 都审查</option>
|
||||
<option value="pr">仅 Pull Request</option>
|
||||
<option value="push">仅分支 Push</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>发布方式
|
||||
<select name="publish_mode">
|
||||
<option value="inline">PR 内联评论</option>
|
||||
<option value="issue-only">仅 Issue</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>阻断级别 <input name="block_severity" placeholder="critical,high" /></label>
|
||||
<label>阻断类别 <input name="block_categories" placeholder="security" /></label>
|
||||
<label>Issue 标签 <input name="issue_labels" placeholder="code-review" /></label>
|
||||
<label>最大意见数 <input name="max_comments" type="number" min="1" max="200" /></label>
|
||||
<label>并发数 <input name="concurrency" type="number" min="1" max="16" /></label>
|
||||
<label>排除路径 <input name="excludes" placeholder="**/dist/**,**/*.lock" /></label>
|
||||
<label>仓库专用 Gitea Token <input name="gitea_token" type="password" placeholder="留空使用全局" /></label>
|
||||
<label>仓库专用 LLM Key <input name="llm_token" type="password" placeholder="留空使用全局" /></label>
|
||||
<label>LLM 模型 <input name="llm_model" placeholder="留空使用全局" /></label>
|
||||
<label>审查背景 <input name="background_template" placeholder="补充业务上下文" /></label>
|
||||
</div>
|
||||
<div class="checks">
|
||||
<label><input type="checkbox" name="enabled" /> 启用</label>
|
||||
<label><input type="checkbox" name="create_issue" /> 发现问题时创建 Issue</label>
|
||||
<label><input type="checkbox" name="auto_merge" /> 无阻断问题时自动合并</label>
|
||||
<label><input type="checkbox" name="delete_branch" /> 合并后删除分支</label>
|
||||
<label><input type="checkbox" name="fail_on_findings" /> 有意见即判定失败</label>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>自动合并方式
|
||||
<select name="auto_merge_mode">
|
||||
<option value="when_checks_succeed">等待检查通过后合并</option>
|
||||
<option value="immediate">立即合并</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>合并方式
|
||||
<select name="merge_method">
|
||||
<option value="squash">squash</option>
|
||||
<option value="merge">merge</option>
|
||||
<option value="rebase">rebase</option>
|
||||
<option value="rebase-merge">rebase-merge</option>
|
||||
<option value="fast-forward-only">fast-forward-only</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">保存</button>
|
||||
<button type="button" id="discover">测试连接</button>
|
||||
<button type="button" id="modal-close">取消</button>
|
||||
</div>
|
||||
<pre id="repo-result" class="log hidden"></pre>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--panel-2: #1e222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e6e9ef;
|
||||
--muted: #99a2b3;
|
||||
--accent: #4c8dff;
|
||||
--ok: #3fb950;
|
||||
--warn: #d29922;
|
||||
--err: #f85149;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||
}
|
||||
h1 { font-size: 18px; margin: 0; }
|
||||
h2 { font-size: 15px; margin: 0 0 12px; }
|
||||
.status { color: var(--muted); font-size: 13px; }
|
||||
nav { display: flex; gap: 4px; padding: 12px 24px 0; }
|
||||
.tab {
|
||||
background: transparent; border: 1px solid transparent; color: var(--muted);
|
||||
padding: 8px 14px; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.tab.active { background: var(--panel); border-color: var(--border); border-bottom-color: var(--panel); color: var(--text); }
|
||||
main { padding: 0 24px 48px; }
|
||||
.panel { display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 0 8px 8px 8px; padding: 20px; }
|
||||
.panel.active { display: block; }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
button {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 7px 14px; border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.danger:hover { border-color: var(--err); color: var(--err); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
code { background: var(--panel-2); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
|
||||
.tag { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 12px; border: 1px solid var(--border); }
|
||||
.tag.ok { color: var(--ok); border-color: var(--ok); }
|
||||
.tag.err { color: var(--err); border-color: var(--err); }
|
||||
.tag.warn { color: var(--warn); border-color: var(--warn); }
|
||||
.tag.muted { color: var(--muted); }
|
||||
fieldset { border: 1px solid var(--border); border-radius: 8px; margin: 0 0 18px; padding: 16px; }
|
||||
legend { color: var(--muted); padding: 0 6px; }
|
||||
label { display: block; margin-bottom: 10px; color: var(--muted); font-size: 13px; }
|
||||
input, select {
|
||||
display: block; width: 100%; margin-top: 4px; padding: 7px 9px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text); font-size: 13px;
|
||||
}
|
||||
.checks { display: flex; flex-wrap: wrap; gap: 16px; margin: 8px 0 14px; }
|
||||
.checks label { display: flex; align-items: center; gap: 6px; margin: 0; }
|
||||
.checks input { width: auto; margin: 0; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 0 16px; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
|
||||
.log {
|
||||
background: #0a0c10; border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 12px; font-size: 12px; overflow: auto; max-height: 420px; white-space: pre-wrap;
|
||||
}
|
||||
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: flex-start; justify-content: center; padding: 40px 16px; overflow: auto; z-index: 20; }
|
||||
.modal-body { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 22px; width: min(900px, 100%); }
|
||||
.modal-body.narrow { width: min(460px, 100%); }
|
||||
|
||||
/* Keep this last: it has to beat the display rules above. */
|
||||
.hidden { display: none !important; }
|
||||
.banner {
|
||||
margin: 0 0 16px; padding: 10px 14px; border-radius: 8px; font-size: 13px;
|
||||
border: 1px solid var(--err); color: var(--err); background: rgba(248,81,73,0.08);
|
||||
}
|
||||
.banner.ok { border-color: var(--ok); color: var(--ok); background: rgba(63,185,80,0.08); }
|
||||
.empty { color: var(--muted); padding: 18px 4px; }
|
||||
header .right { display: flex; align-items: center; gap: 12px; }
|
||||
.linkish {
|
||||
background: none; border: none; color: var(--muted); cursor: pointer;
|
||||
font-size: 13px; text-decoration: underline; padding: 0;
|
||||
}
|
||||
.linkish:hover { color: var(--accent); }
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
gitea-codereview:
|
||||
build:
|
||||
context: .
|
||||
image: local/gitea-codereview:latest
|
||||
container_name: gitea-codereview
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
CR_HOST: 0.0.0.0
|
||||
CR_PORT: 8090
|
||||
CR_DATA_DIR: /data
|
||||
CR_GITEA_URL: ${CR_GITEA_URL:-http://192.168.31.51}
|
||||
CR_OCR_COMMAND: ocr
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "${CR_HOST_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- codereview-data:/data
|
||||
# Mount a custom OCR rule file here and set the path in the UI.
|
||||
# - ./rule.json:/etc/gitea-codereview/rule.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8090/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
codereview-data:
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "gitea-codereview",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Automated Gitea code review service backed by OpenCodeReview (OCR)",
|
||||
"type": "module",
|
||||
"main": "app/server.js",
|
||||
"scripts": {
|
||||
"start": "node app/server.js",
|
||||
"migrate": "node app/lib/migrate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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 } 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 honours glob patterns and ref prefixes", () => {
|
||||
assert.ok(branchMatches("main", "*"));
|
||||
assert.ok(branchMatches("main", ""));
|
||||
assert.ok(branchMatches("refs/heads/main", "main"));
|
||||
assert.ok(branchMatches("release/1.2", "release/*"));
|
||||
assert.ok(!branchMatches("feature/x", "release/*"));
|
||||
assert.ok(branchMatches("feature/x", "main, feature/*"));
|
||||
assert.ok(branchMatches("a/b/c", "a/**"));
|
||||
});
|
||||
|
||||
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.base_branch, "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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user