Files
codex-app-server/cloud/review.py
T

190 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自动代码评审:评审队列记录。
评审队列是服务端的一张登记表,不跑模型:调用方对某个任务
发起评审时记一条 requested 记录,评审执行方(目前是
host.py code-review,走协议 review/start)跑完后把 findings
写回同一条记录,控制台评审区据此展示队列与结果。
与 peer_messages 同款分工:本模块只含纯表操作与协议目标
构造,不 import cloud 其它模块,避免循环 import;服务端
的表迁移委托到 ensure_reviews_table,失败不伪造成功。
协议锚点(protocol/codex_app_server_protocol.v2.schemas.json,
逐个对过枚举,不编造):
- 方法名 review/start,参数 ReviewStartParams:threadId 与
target 必填,delivery 可空(inline 为默认,detached 已
废弃,故本模块不产生 detached)。
- ReviewTarget 四种:uncommittedChanges(工作区未提交改动)、
baseBranch(须带 branch)、commit(须带 sha,title 可空)、
custom(须带 instructions)。
状态机:requested(已登记,未执行)-> running(执行中,
目前仅 host CLI 执行时可由调用方先置位,服务端不自动
流转)-> completed / failed(写 findings 时落终态)。
服务端不替执行方编造 findings:findings 为空时记录仍是
requested,控制台如实显示“暂无 findings”。
"""
import time
import uuid
REVIEW_TARGET_TYPES = (
"uncommittedChanges", "baseBranch", "commit", "custom")
REVIEW_STATUSES = ("requested", "running", "completed", "failed")
_REVIEW_COLUMNS = (
"id", "task_id", "project_id", "target_type", "branch", "sha",
"instructions", "status", "findings", "created_by",
"created_at", "updated_at")
def build_review_target(target_type, branch=None, sha=None,
instructions=None):
"""按协议构造 ReviewTarget dict;组合非法时 ValueError,
调用方转 400,绝不把非法目标发给 app-server。"""
if target_type == "uncommittedChanges":
return {"type": "uncommittedChanges"}
if target_type == "baseBranch":
if not branch or not isinstance(branch, str) or not branch.strip():
raise ValueError("baseBranch target requires branch")
return {"type": "baseBranch", "branch": branch.strip()}
if target_type == "commit":
if not sha or not isinstance(sha, str) or not sha.strip():
raise ValueError("commit target requires sha")
return {"type": "commit", "sha": sha.strip()}
if target_type == "custom":
if not instructions or not isinstance(instructions, str) \
or not instructions.strip():
raise ValueError("custom target requires instructions")
return {"type": "custom", "instructions": instructions.strip()}
raise ValueError("unknown target_type: %r" % (target_type,))
def ensure_reviews_table(conn):
"""幂等建评审队列表。调用方(服务端接口)在每次读写前
调它,老库第一次跑到这里时自动补表,不丢数据。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS task_reviews("
"id TEXT PRIMARY KEY, "
"task_id TEXT, "
"project_id TEXT, "
"target_type TEXT, "
"branch TEXT, "
"sha TEXT, "
"instructions TEXT, "
"status TEXT, "
"findings TEXT, "
"created_by TEXT, "
"created_at DOUBLE PRECISION, "
"updated_at DOUBLE PRECISION)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_reviews_task"
" ON task_reviews(task_id, created_at)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_reviews_project"
" ON task_reviews(project_id, created_at)")
def _row_to_dict(row):
"""行转 dict:兼容 dict 行、sqlite3.Row 与裸元组(按
_REVIEW_COLUMNS 的 SELECT 顺序)。"""
if row is None:
return None
if isinstance(row, dict):
return {c: row.get(c) for c in _REVIEW_COLUMNS}
try:
keys = row.keys()
except AttributeError:
keys = None
if keys is not None:
d = dict(row)
return {c: d.get(c) for c in _REVIEW_COLUMNS}
return dict(zip(_REVIEW_COLUMNS, row))
def _select_cols():
return ", ".join(_REVIEW_COLUMNS)
def create_review(conn, task_id, project_id, target_type, created_by,
branch=None, sha=None, instructions=None):
"""登记一条评审,返回记录 dict。先校验目标组合(非法
时 ValueError,调用方转 400,表里不留脏行),成功才
commit。"""
build_review_target(target_type, branch, sha, instructions)
ensure_reviews_table(conn)
now = time.time()
review_id = uuid.uuid4().hex
conn.execute(
"INSERT INTO task_reviews(id, task_id, project_id,"
" target_type, branch, sha, instructions, status,"
" findings, created_by, created_at, updated_at)"
" VALUES(?, ?, ?, ?, ?, ?, ?, 'requested', NULL, ?, ?, ?)",
(review_id, task_id, project_id, target_type,
branch.strip() if isinstance(branch, str) else None,
sha.strip() if isinstance(sha, str) else None,
instructions.strip() if isinstance(instructions, str) else None,
created_by, now, now),
)
conn.commit()
return get_review(conn, review_id)
def list_reviews(conn, task_id):
"""某任务的评审队列,按创建时间升序(先登记的先审)。"""
ensure_reviews_table(conn)
rows = conn.execute(
"SELECT %s FROM task_reviews WHERE task_id = ?"
" ORDER BY created_at, id" % _select_cols(),
(task_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
def list_project_reviews(conn, project_id):
"""某项目的评审队列(供未来项目级队列端点复用;本批
服务端只暴露任务级队列,函数先备好不接线)。"""
ensure_reviews_table(conn)
rows = conn.execute(
"SELECT %s FROM task_reviews WHERE project_id = ?"
" ORDER BY created_at, id" % _select_cols(),
(project_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
def get_review(conn, review_id):
"""取单条评审记录,不存在时返回 None(调用方转 404)。"""
ensure_reviews_table(conn)
row = conn.execute(
"SELECT %s FROM task_reviews WHERE id = ?" % _select_cols(),
(review_id,)).fetchone()
return _row_to_dict(row)
def update_review(conn, review_id, status=None, findings=None):
"""更新评审状态与 findings,返回更新后记录,不存在时
返回 None。status 非法时 ValueError(调用方转 400);
findings 为字符串时照存(空串也存,区别于未写回的
NULL);不传 status 时 findings 写回默认落 completed,
即“有结果即完成”,调用方可显式传 failed 覆盖。"""
if status is not None and status not in REVIEW_STATUSES:
raise ValueError("unknown review status: %r" % (status,))
if findings is not None and not isinstance(findings, str):
raise ValueError("findings must be a string")
ensure_reviews_table(conn)
record = get_review(conn, review_id)
if record is None:
return None
new_status = status
if new_status is None:
new_status = "completed" if findings is not None \
else record["status"]
new_findings = findings if findings is not None \
else record["findings"]
conn.execute(
"UPDATE task_reviews SET status = ?, findings = ?,"
" updated_at = ? WHERE id = ?",
(new_status, new_findings, time.time(), review_id))
conn.commit()
return get_review(conn, review_id)