From 6ba671e051bbf1dc7eea214d23c8b9d290e2acde Mon Sep 17 00:00:00 2001 From: host Date: Tue, 27 Oct 2026 05:43:56 +0800 Subject: [PATCH] =?UTF-8?q?GitHub=20=E4=BB=93=E5=BA=93=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=EF=BC=9A=E5=AE=89=E8=A3=85=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=88=97=E8=A1=A8=20+=20PATCH=20=E8=A1=A5=E4=BB=93=E5=BA=93=20?= =?UTF-8?q?+=20=E6=8E=A7=E5=88=B6=E5=8F=B0=E4=B8=8B=E6=8B=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloud/README.md | 30 ++++++ cloud/console.html | 58 +++++++++++ cloud/server.py | 85 ++++++++++++++++ cloud/tests/test_github_repos.py | 170 +++++++++++++++++++++++++++++++ 4 files changed, 343 insertions(+) create mode 100644 cloud/tests/test_github_repos.py diff --git a/cloud/README.md b/cloud/README.md index caf0add..f3d65d6 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -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/.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` diff --git a/cloud/console.html b/cloud/console.html index e6c1988..c45bf88 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -107,6 +107,7 @@
+
@@ -136,6 +137,12 @@ +
+ + @@ -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 = "" + + githubReposCache.map(function (r) { + return ""; + }).join(""); + var s1 = $("#newProjectRepoSelect"); if (s1) s1.innerHTML = opts; + var s2 = $("#envRepoSelect"); + if (s2) s2.innerHTML = "" + + githubReposCache.map(function (r) { + return ""; + }).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 = "加载失败:" + esc(e.message) + ""; } @@ -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 = "新建失败:" + esc(e.message) + ""; } }; +$("#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 = "仓库已保存,现在可以建环境了"; + loadProjects(); loadEnvironments(); + } catch (e) { $("#envRepoMsg").innerHTML = "保存失败:" + esc(e.message) + ""; } +}; function gatherAttachments() { // 三路输入合并成 [{path, file}]:文件路用文件名,文件夹路用 // webkitRelativePath(含子目录),图片路用文件名;同路径只留 @@ -810,6 +867,7 @@ loadHealth(); loadProjects(); loadWorkers(); loadQuota(); +loadGithubRepos(); // 回访用户 key 已在 localStorage 里:初始化也要加载模型列表, // 否则下拉永远只有默认项(此前只在点“保存”时调一次, // 2026-10-27 实测:存过 key 后重开页面模型选不了)。 diff --git a/cloud/server.py b/cloud/server.py index de042b5..03aff84 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -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": diff --git a/cloud/tests/test_github_repos.py b/cloud/tests/test_github_repos.py new file mode 100644 index 0000000..a56a857 --- /dev/null +++ b/cloud/tests/test_github_repos.py @@ -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()