From ed172bf3694937669a6c50556b08269d51cfe498 Mon Sep 17 00:00:00 2001 From: kgod <1257628228@qq.com> Date: Sun, 20 Sep 2026 12:09:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Gitea=20=E8=87=AA=E5=8A=A8=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A5=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 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 指向的实例不可达, 且后台配置与任务历史需要独立进程承载。 --- .env.example | 33 +++ .gitattributes | 3 + .gitignore | 4 + Dockerfile | 30 ++ README.md | 219 +++++++++++++++ app/lib/db.js | 271 ++++++++++++++++++ app/lib/diff.js | 133 +++++++++ app/lib/gitea.js | 268 ++++++++++++++++++ app/lib/ocr.js | 272 ++++++++++++++++++ app/lib/queue.js | 79 ++++++ app/lib/review.js | 641 ++++++++++++++++++++++++++++++++++++++++++ app/server.js | 521 ++++++++++++++++++++++++++++++++++ app/static/app.js | 426 ++++++++++++++++++++++++++++ app/static/index.html | 192 +++++++++++++ app/static/style.css | 89 ++++++ docker-compose.yml | 36 +++ package.json | 16 ++ tests/core.test.js | 218 ++++++++++++++ 18 files changed, 3451 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/lib/db.js create mode 100644 app/lib/diff.js create mode 100644 app/lib/gitea.js create mode 100644 app/lib/ocr.js create mode 100644 app/lib/queue.js create mode 100644 app/lib/review.js create mode 100644 app/server.js create mode 100644 app/static/app.js create mode 100644 app/static/index.html create mode 100644 app/static/style.css create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 tests/core.test.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ea51e44 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..378d812 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Keep line endings stable: the app runs in a Linux container. +* text=auto eol=lf +*.png binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..654f882 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +data/ +node_modules/ +*.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81fb01d --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..729daa7 --- /dev/null +++ b/README.md @@ -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 `。 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `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":""}' +``` + +## 行为说明 + +**审查范围**: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] / · 存在阻断级代码问题`。同一分支再次出现阻断问题时更新该 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 有合并权限。 \ No newline at end of file diff --git a/app/lib/db.js b/app/lib/db.js new file mode 100644 index 0000000..ab76957 --- /dev/null +++ b/app/lib/db.js @@ -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; +} \ No newline at end of file diff --git a/app/lib/diff.js b/app/lib/diff.js new file mode 100644 index 0000000..27a336c --- /dev/null +++ b/app/lib/diff.js @@ -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 }; +} \ No newline at end of file diff --git a/app/lib/gitea.js b/app/lib/gitea.js new file mode 100644 index 0000000..e639185 --- /dev/null +++ b/app/lib/gitea.js @@ -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} 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); +} \ No newline at end of file diff --git a/app/lib/ocr.js b/app/lib/ocr.js new file mode 100644 index 0000000..4f82adf --- /dev/null +++ b/app/lib/ocr.js @@ -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); +} \ No newline at end of file diff --git a/app/lib/queue.js b/app/lib/queue.js new file mode 100644 index 0000000..1229271 --- /dev/null +++ b/app/lib/queue.js @@ -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); + } +} \ No newline at end of file diff --git a/app/lib/review.js b/app/lib/review.js new file mode 100644 index 0000000..411c9f2 --- /dev/null +++ b/app/lib/review.js @@ -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 = ""; +export const COMMENT_MARKER = ""; +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( + "", + `由 gitea-codereview 基于 [OpenCodeReview](https://github.com/alibaba/open-code-review) 生成 · job #${job.id}`, + ); + 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("", `job #${job.id} · 由 gitea-codereview 生成`); + 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\njob #${job.id}`); + } + } 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"); +} \ No newline at end of file diff --git a/app/server.js b/app/server.js new file mode 100644 index 0000000..046428a --- /dev/null +++ b/app/server.js @@ -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 }; \ No newline at end of file diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..537ce44 --- /dev/null +++ b/app/static/app.js @@ -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 = '还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。'; + return; + } + tbody.innerHTML = state.repos.map((r) => ` + + ${esc(r.owner)}/${esc(r.name)}
${esc(r.base_branch)} + ${esc(r.branch_patterns)} + ${esc(scopeLabel(r.review_scope))} + ${r.enabled ? '启用' : '停用'} + ${r.auto_merge ? `${esc(r.merge_method)}` : '否'} + ${esc(r.block_severity || "—")} + + + + + + `).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 = '暂无任务。仓库收到 push 或 Pull Request 后会出现在这里。'; + return; + } + tbody.innerHTML = state.jobs.map((j) => ` + + ${j.id} + ${esc(j.owner)}/${esc(j.name)} + ${esc(j.ref_name)}${j.pr_number ? ` PR #${j.pr_number}` : ""} + ${esc(String(j.to_sha).slice(0, 10))} + ${esc(j.status)}${j.phase ? ` ${esc(j.phase)}` : ""} + ${j.findings ?? 0} + ${j.blocking ? `${j.blocking}` : "0"} + ${j.merged ? '已合并' : ""} + ${fmtTime(j.started_at || j.created_at)} + + `).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); \ No newline at end of file diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..e37b082 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,192 @@ + + + + + +Gitea 代码审查 + + + + +
+

Gitea 代码审查

+
加载中…
+
+ + + +
+ + +
+
+

已配置仓库

+ +
+ + + + + + + + +
仓库分支过滤范围启用自动合并阻断级别
+
+ +
+
+

审查任务

+ +
+ + + + + + +
#仓库分支提交状态意见阻断合并开始
+ +
+ +
+

全局设置

+
+
+ Gitea + + + + +

Webhook 地址:(在仓库 Settings → Webhooks 中添加,密钥填上面这一项)

+
+
+ LLM + + + + + + + +
+
+ + + +
+ +
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..d850660 --- /dev/null +++ b/app/static/style.css @@ -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); } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4fb7fb1 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..59d353a --- /dev/null +++ b/package.json @@ -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": {} +} \ No newline at end of file diff --git a/tests/core.test.js b/tests/core.test.js new file mode 100644 index 0000000..c0b27c7 --- /dev/null +++ b/tests/core.test.js @@ -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(); + } +}); \ No newline at end of file