GitHub 仓库选择菜单:安装仓库列表 + PATCH 补仓库 + 控制台下拉
This commit is contained in:
1 parent
5ba4d35839
commit
6ba671e051
4 files changed
+343
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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 后重开页面模型选不了)。
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user