GitHub 仓库选择菜单:安装仓库列表 + PATCH 补仓库 + 控制台下拉

This commit is contained in:
host committed 2026-10-27 05:43:56 +08:00
1 parent 5ba4d35839
commit 6ba671e051
4 files changed
+343

No files matched your search

+30
View File
@@ -871,6 +871,36 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
`/tasks`、`/projects`、`/workers`、`/health` 同款,否则页面能打开
但环境区永远加载失败)。
## GitHub 仓库选择(建项目 / 给已有项目补仓库)
控制台以前没有仓库选择菜单:建项目时 repo_url 只是个可选的
手填文本框,不填也能建出项目,之后建环境必被 400 挡住
(“project has no repo_url”),且没有修改接口,死结只能重建
项目。本节补上选择菜单与补仓库接口。
- 列表来源:`GET /github/repos`(需认证)列出本服务 GitHub
App 安装记录里的仓库(installation_repositories 事件同步进
`github_app_installations` 表,字段 installation_id /
account_login / repo_full_name,clone_url 由 full_name 拼出
`https://github.com/<full_name>.git`)。只含仓库名,不含密钥。
- 建项目:仓库下拉(从上述接口加载)+ 手填文本框并存:选下拉
即把 clone_url 填进文本框,仍按原有 body 提交;没进 App 安装
的公开仓库继续手动填 URL,不受影响。
- 补仓库:`PATCH /projects/{id}`(项目 owner 才能调, maintainer
也不行——改项目归属配置按 manage_project 计),body 必须含
repo_url:字符串走与创建时同一套 validate_repo_url 校验(带凭
证的 URL 拒绝),null 或空串表示清空。改完记 update_project
审计。环境区在当前项目无仓库时直接亮出同一下拉 + “保存仓库”
按钮,保存后立即可以建环境(截图里的死结就卡在这里)。
- 快照语义不变:补仓库只改 projects 行,已建环境的 repo_url
快照不动(仍用建环境时的仓库),新环境才用新值。
- 信任模型说明(诚实边界):下拉没有放宽任何权限——手填 URL
本来就能填任意地址,选下拉与手填是同一能力;按用户隔离仓库
可见范围(例如只列该用户 GitHub 身份名下的安装仓库)是后续
工作,本轮不做。公网部署时 nginx 须同批加
`location ^~ /github/repos`(`/projects` 已有 location,PATCH
走它,不用重复加)。
## 租户配额与计费
- 口径:配额是按用户、按 UTC 自然月的 USD 上限(`user_quotas`
+58
View File
@@ -107,6 +107,7 @@
</div>
<div class="bar">
<input id="newProjectName" type="text" placeholder="新项目名">
<select id="newProjectRepoSelect"><option value="">(从 GitHub App 安装仓库里选,或手动输入)</option></select>
<input id="newProjectRepo" type="text" placeholder="repo_url(可选)">
<button id="createProject">新建项目</button>
</div>
@@ -136,6 +137,12 @@
</select>
<button id="createEnvironment">新建环境</button>
</div>
<div class="bar"><span id="envRepoLine" class="muted"></span></div>
<div class="bar" id="envRepoBar" style="display:none">
<select id="envRepoSelect"><option value="">(从 GitHub App 安装仓库里选)</option></select>
<button id="saveEnvRepo">保存仓库</button>
</div>
<span id="envRepoMsg" class="muted"></span>
<span id="envMsg" class="muted"></span>
</section>
@@ -343,9 +350,29 @@ async function loadQuota() {
} catch (e) { el.textContent = "配额加载失败:" + e.message; }
}
var projectsCache = [];
var githubReposCache = [];
async function loadGithubRepos() {
try {
var d = await api("/github/repos", "GET");
githubReposCache = d.repos || [];
var opts = "<option value=''>(从 GitHub App 安装仓库里选,或手动输入)</option>" +
githubReposCache.map(function (r) {
return "<option value='" + esc(r.clone_url) + "'>" + esc(r.repo_full_name) + "</option>";
}).join("");
var s1 = $("#newProjectRepoSelect"); if (s1) s1.innerHTML = opts;
var s2 = $("#envRepoSelect");
if (s2) s2.innerHTML = "<option value=''>(从 GitHub App 安装仓库里选)</option>" +
githubReposCache.map(function (r) {
return "<option value='" + esc(r.clone_url) + "'>" + esc(r.repo_full_name) + "</option>";
}).join("");
} catch (e) { /* 未登录或无安装记录时只留手动输入,不报错 */ }
}
async function loadProjects() {
try {
var d = await api("/projects", "GET");
projectsCache = d.projects || [];
var sel = $("#projectSelect");
var cur = sel.value;
sel.innerHTML = "";
@@ -412,6 +439,20 @@ async function loadEnvironments() {
sel.appendChild(o);
});
if (cur && Array.prototype.some.call(sel.options, function (o) { return o.value === cur && !o.disabled; })) sel.value = cur;
// 项目仓库行:有仓库就显示;没仓库就亮出补仓库的下拉(截图里
// 那个 400 的死结就卡在这里——不先补仓库,环境永远建不出来)。
var proj = null;
for (var i = 0; i < projectsCache.length; i++) { if (projectsCache[i].id === pid) { proj = projectsCache[i]; break; } }
if (proj && proj.repo_url) {
$("#envRepoLine").textContent = "仓库:" + proj.repo_url;
$("#envRepoBar").style.display = "none";
} else if (proj) {
$("#envRepoLine").textContent = "该项目未绑定仓库,环境无法创建——先选一个仓库保存";
$("#envRepoBar").style.display = "";
} else {
$("#envRepoLine").textContent = "";
$("#envRepoBar").style.display = "none";
}
} catch (e) {
$("#envList").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>";
}
@@ -630,6 +671,7 @@ $("#saveKey").onclick = function () {
if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); }
if (getKey()) loadRelayModels();
loadQuota();
loadGithubRepos();
};
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
$("#refreshHealth").onclick = loadHealth;
@@ -669,6 +711,21 @@ $("#createEnvironment").onclick = async function () {
loadEnvironments();
} catch (e) { $("#envMsg").innerHTML = "<span class='err'>新建失败:" + esc(e.message) + "</span>"; }
};
$("#newProjectRepoSelect").onchange = function () {
var v = this.value;
if (v) $("#newProjectRepo").value = v;
};
$("#saveEnvRepo").onclick = async function () {
var pid = $("#projectSelect").value;
var repo = $("#envRepoSelect").value;
if (!pid) { $("#envRepoMsg").textContent = "先选项目"; return; }
if (!repo) { $("#envRepoMsg").textContent = "先选一个仓库"; return; }
try {
await api("/projects/" + encodeURIComponent(pid), "PATCH", { repo_url: repo });
$("#envRepoMsg").innerHTML = "<span class='ok'>仓库已保存,现在可以建环境了</span>";
loadProjects(); loadEnvironments();
} catch (e) { $("#envRepoMsg").innerHTML = "<span class='err'>保存失败:" + esc(e.message) + "</span>"; }
};
function gatherAttachments() {
// 三路输入合并成 [{path, file}]:文件路用文件名,文件夹路用
// webkitRelativePath(含子目录),图片路用文件名;同路径只留
@@ -810,6 +867,7 @@ loadHealth();
loadProjects();
loadWorkers();
loadQuota();
loadGithubRepos();
// 回访用户 key 已在 localStorage 里:初始化也要加载模型列表,
// 否则下拉永远只有默认项(此前只在点“保存”时调一次,
// 2026-10-27 实测:存过 key 后重开页面模型选不了)。
+85
View File
@@ -930,6 +930,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def do_DELETE(self):
self._dispatch("DELETE")
def do_PATCH(self):
# PATCH 之前没有入口(BaseHTTPRequestHandler 默认回 501),
# 项目补仓库是第一个 PATCH 路由;与其它方法一样走 _dispatch。
self._dispatch("PATCH")
def _dispatch(self, method):
parsed = urlparse(self.path)
path = parsed.path
@@ -1099,6 +1104,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._models_overview(user_id)
elif path == "/relay/models" and method == "GET":
self._relay_models(user_id)
elif path == "/github/repos" and method == "GET":
self._github_repos_list(user_id)
elif path.startswith("/projects/"):
self._dispatch_project(user_id, method, path, query)
elif path == "/environments" and method == "POST":
@@ -1420,6 +1427,81 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
store.audit(user_id, project_id, "create_project", name)
self._send_json(201, project)
def _update_project(self, user_id, project_id):
"""PATCH /projects/{id}:项目 owner 可改 repo_url(建后补仓
库,解“建项目时没填仓库、之后建环境一直 400”的死结)。
repo_url 缺 key 时 400;null 或空串表示清空;字符串走与
创建时同一套 validate_repo_url 校验(带凭证的 URL 拒绝)。
只改 projects 行,不动已建环境的快照 repo_url——已建环境
仍用建环境时的仓库,新环境才用新值。改完记 update_project
审计。"""
err = self._check_project(user_id, project_id, "manage_project")
if err:
self._send_json(err[0], {"error": err[1]})
return
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
if "repo_url" not in body:
self._send_json(400, {"error": "repo_url required"})
return
repo_url = body.get("repo_url")
if repo_url is not None and repo_url != "":
if not isinstance(repo_url, str):
self._send_json(400, {"error": "repo_url must be a string"})
return
try:
repo_url = validate_repo_url(repo_url)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
else:
repo_url = None
conn = self._db()
try:
row = conn.execute(
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
" FROM projects WHERE id = ?",
(project_id,)).fetchone()
if row is None:
self._send_json(404, {"error": "project not found"})
return
conn.execute(
"UPDATE projects SET repo_url = ? WHERE id = ?",
(repo_url, project_id))
conn.commit()
row = conn.execute(
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
" FROM projects WHERE id = ?",
(project_id,)).fetchone()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "update_project",
repo_url or "(cleared)")
self._send_json(200, {"project": _project_to_dict(row)})
def _github_repos_list(self, user_id):
"""GET /github/repos:列出本服务 GitHub App 安装记录里的仓
库(installation_repositories 事件同步进 github_app_
installations 表),给控制台建项目/补仓库时的下拉菜单用。
任何已认证用户可列(只含仓库名,不含密钥);选了哪个仓库
只影响调用者自己有管理权的项目(PATCH 仍按 owner 校验)。
手填 URL 的能力不变——没进 App 安装的公开仓库仍可手动填。"""
conn = self._db()
try:
rows = list_installations(conn)
finally:
conn.close()
repos = [{
"installation_id": r["installation_id"],
"account_login": r["account_login"],
"repo_full_name": r["repo_full_name"],
"clone_url": "https://github.com/%s.git" % r["repo_full_name"],
} for r in rows]
self._send_json(200, {"repos": repos})
def _list_projects(self, user_id):
conn = self._db()
try:
@@ -1467,6 +1549,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if not project_id:
self._send_json(404, {"error": "not found"})
return
if len(parts) == 1 and method == "PATCH":
self._update_project(user_id, project_id)
return
# /projects/{id}/memory
if len(parts) == 2 and parts[1] == "memory":
if method == "POST":
+170
View File
@@ -0,0 +1,170 @@
"""GitHub 仓库选择菜单单测:安装仓库列表 + 项目 PATCH 补仓库。
不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时
SQLite;安装仓库行直接由 record_installation_repos 写入测试库
(模拟 installation_repositories webhook 同步),验证列表端点、
PATCH 权限与校验、以及截图里的死结——无仓库项目建环境 400,
PATCH 补仓库后建环境 201。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.github_app import record_installation_repos
from cloud.server import create_server
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.github_app import record_installation_repos
from cloud.server import create_server
def _req(port, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
class ServerGithubReposTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.other_id, self.other_key = self.auth.create_user("other")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
s, proj = _req(self.port, "POST", "/projects",
{"name": "repoproj"}, self.owner_key)
self.assertEqual(s, 201, proj)
self.project_id = proj["id"]
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.tmp.cleanup()
def _seed_installations(self):
conn = sqlite3.connect(self.db_path)
try:
record_installation_repos(
conn, 175380082, "openqj",
["openqj/codex-app-server", "openqj/demo"])
finally:
conn.close()
def _project_repo(self, key):
s, d = _req(self.port, "GET", "/projects", key=key)
self.assertEqual(s, 200, d)
for p in d["projects"]:
if p["id"] == self.project_id:
return p["repo_url"]
self.fail("project not in list")
def test_list_requires_auth(self):
s, d = _req(self.port, "GET", "/github/repos")
self.assertEqual(s, 401, d)
def test_list_returns_seeded_installations(self):
self._seed_installations()
s, d = _req(self.port, "GET", "/github/repos", key=self.owner_key)
self.assertEqual(s, 200, d)
names = [r["repo_full_name"] for r in d["repos"]]
self.assertEqual(names, ["openqj/codex-app-server", "openqj/demo"])
self.assertEqual(
d["repos"][0]["clone_url"],
"https://github.com/openqj/codex-app-server.git")
def test_patch_repo_url_owner(self):
url = "https://github.com/openqj/demo.git"
s, d = _req(self.port, "PATCH",
"/projects/" + self.project_id,
{"repo_url": url}, self.owner_key)
self.assertEqual(s, 200, d)
self.assertEqual(d["project"]["repo_url"], url)
self.assertEqual(self._project_repo(self.owner_key), url)
def test_patch_forbidden_and_validation(self):
url = "https://github.com/openqj/demo.git"
s, d = _req(self.port, "PATCH",
"/projects/" + self.project_id,
{"repo_url": url}, self.other_key)
self.assertEqual(s, 403, d)
s, d = _req(self.port, "PATCH",
"/projects/" + self.project_id,
{"repo_url": "not a url"}, self.owner_key)
self.assertEqual(s, 400, d)
s, d = _req(self.port, "PATCH",
"/projects/" + self.project_id,
{"repo_url": "https://user:pass@github.com/a/b.git"},
self.owner_key)
self.assertEqual(s, 400, d)
s, d = _req(self.port, "PATCH",
"/projects/" + self.project_id,
{}, self.owner_key)
self.assertEqual(s, 400, d)
s, d = _req(self.port, "PATCH", "/projects/no-such-project",
{"repo_url": url}, self.owner_key)
self.assertEqual(s, 404, d)
def test_patch_clears_repo_url(self):
url = "https://github.com/openqj/demo.git"
s, _ = _req(self.port, "PATCH",
"/projects/" + self.project_id,
{"repo_url": url}, self.owner_key)
self.assertEqual(s, 200)
s, d = _req(self.port, "PATCH",
"/projects/" + self.project_id,
{"repo_url": None}, self.owner_key)
self.assertEqual(s, 200, d)
self.assertIsNone(d["project"]["repo_url"])
def test_env_blocked_then_unblocked_by_patch(self):
# 截图场景:无仓库项目建环境 400。
s, d = _req(self.port, "POST", "/environments",
{"project_id": self.project_id, "name": "env1"},
self.owner_key)
self.assertEqual(s, 400, d)
self.assertIn("no repo_url", d["error"])
# PATCH 补仓库后同一请求 201。
s, _ = _req(self.port, "PATCH",
"/projects/" + self.project_id,
{"repo_url": "https://github.com/openqj/demo.git"},
self.owner_key)
self.assertEqual(s, 200)
s, d = _req(self.port, "POST", "/environments",
{"project_id": self.project_id, "name": "env1"},
self.owner_key)
self.assertEqual(s, 201, d)
if __name__ == "__main__":
unittest.main()