fix(gitea)+feat(mcp): PR 409 容错 + apply_onboarding_pack/list_all_projects agent 按钮

- gitea_api_create_pull_request: 409 时自动查 open PR 列表,返回已存在的 PR
- MCP 新增 apply_onboarding_pack(接入包分发 UI 按钮的 agent 投影)
- MCP 新增 list_all_projects(绕过 group 限制,列出全库项目及注入状态)
This commit is contained in:
lanrtop 2026-07-03 21:12:06 +09:00
parent dccca234b5
commit def53d739c
3 changed files with 170 additions and 19 deletions

View File

@ -0,0 +1,28 @@
# PR 代码质量检查Gitea Actions
# 在 PR 合并前执行 build / lint确保代码可编译且风格合规
name: PR Check
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: windows
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm lint --if-present
- run: pnpm test --if-present --passWithNoTests

View File

@ -657,9 +657,8 @@ pub fn gitea_list_linked_repos() -> Result<Vec<serde_json::Value>, String> {
Ok(rows)
}
/// 通过炼境存储的凭证为项目创建 PR返回 JSON 字符串(含 pr_url / pr_number
///
/// 设计意图MCP 工具调用此函数,避免 Claude 在 bash 里提取 token 再 curl会被权限系统拒绝
/// 通过炼境存储的凭证为项目创建 PR返回 JSON 字符串(含 pr_url / pr_number / already_exists
/// 409 冲突PR 已存在)时自动查找并返回现有 PRalready_exists=true。
pub fn gitea_api_create_pull_request(
project_id: &str,
head: &str,
@ -677,28 +676,46 @@ pub fn gitea_api_create_pull_request(
let (base_url, token) = instance_auth(&conn, &instance_id)?;
let url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls");
let resp = ureq::post(&url)
let create_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls");
let (json, already_exists) = match ureq::post(&create_url)
.set("Authorization", &format!("token {token}"))
.set("Content-Type", "application/json")
.send_json(serde_json::json!({
"head": head,
"base": base,
"title": title,
"body": body,
}))
.map_err(|e| api_err("创建 PR 失败", e))?;
.send_json(serde_json::json!({ "head": head, "base": base, "title": title, "body": body }))
{
Ok(resp) => (resp.into_json::<serde_json::Value>().map_err(|e| format!("解析响应失败: {e}"))?, false),
Err(ureq::Error::Status(409, _)) => {
// PR 已存在,列出开放 PR 找到匹配的分支
let list_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls?state=open&limit=30");
let prs: serde_json::Value = ureq::get(&list_url)
.set("Authorization", &format!("token {token}"))
.call()
.map_err(|e| api_err("获取已有 PR 列表失败", e))?
.into_json()
.map_err(|e| format!("解析 PR 列表失败: {e}"))?;
let arr = prs.as_array().ok_or("PR 列表格式错误")?;
let existing = arr.iter()
.find(|pr| {
pr["head"]["ref"].as_str() == Some(head)
|| pr["head"]["label"].as_str().map(|l| l.ends_with(head)).unwrap_or(false)
})
.cloned()
.ok_or_else(|| format!("PR 已存在但找不到head={head}"))?;
(existing, true)
}
Err(e) => return Err(api_err("创建 PR 失败", e)),
};
let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?;
let pr_number = json["number"].as_u64().unwrap_or(0);
let pr_url = json["html_url"].as_str().unwrap_or("").to_string();
let pr_title = json["title"].as_str().unwrap_or(title).to_string();
serde_json::to_string(&serde_json::json!({
"pr_number": pr_number,
"pr_url": pr_url,
"head": head,
"base": base,
"title": title,
"title": pr_title,
"already_exists": already_exists,
})).map_err(|e| e.to_string())
}

View File

@ -670,6 +670,29 @@ fn tools_list_result() -> Value {
},
"required": ["project_id", "workflow_type"]
}
},
{
"name": "list_all_projects",
"description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "apply_onboarding_pack",
"description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow 模板(已存在则原位升级 ONBOARDING_MANAGED 块,绝不覆盖块外用户内容),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板版本升级后对存量项目重新调用即可推送新版本。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
}
},
"required": ["project_id"]
}
}
]
})
@ -868,6 +891,34 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value {
let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::cicd::distribute_workflow_for_project(pid, wt)
}
"list_all_projects" => list_all_projects(),
"apply_onboarding_pack" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(&gid, pid).and_then(|root| {
let path = root.to_string_lossy().to_string();
crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string())
.map(|report| {
let template_lines: Vec<String> = report.templates.iter()
.map(|t| {
let icon = match t.action.as_str() {
"written" => "✅ written",
"updated" => "🔄 updated",
"appended" => " appended",
"skipped" => "⏭ skipped",
_ => "❌ failed",
};
format!(" {icon}: {}", t.name)
})
.collect();
format!(
"接入包分发完成project_id={}\n\n模板:\n{}\n\nbuff{}",
pid,
template_lines.join("\n"),
report.buff.status
)
})
})
}
_ => Err(format!("未知工具: {}", tool_name)),
})
.await;
@ -1219,6 +1270,61 @@ fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result<
Ok(md)
}
fn list_all_projects() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path,
pgm.group_id
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
LEFT JOIN project_group_map pgm ON pgm.project_id = pw.id
ORDER BY pp.name"
).map_err(|e| e.to_string())?;
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>, Option<String>)> =
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("炼境暂无登记项目。".to_string());
}
let mut out = format!("## 炼境全量项目(共 {} 个)\n\n", rows.len());
for (id, name, platform, win_path, wsl_path, group_id) in &rows {
// 判断是否已注入:检查 liangjing.json 是否存在
let project_path = if platform.as_deref() == Some("wsl") {
wsl_path.as_ref().map(|p| {
let conn2 = db::pool().get().ok();
let distro = conn2.and_then(|c| c.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get::<_,String>(0)
).ok()).unwrap_or_else(|| "Ubuntu".to_string());
let inner = p.trim_start_matches('/').replace('/', "\\");
format!("\\\\wsl.localhost\\{}\\{}", distro, inner)
})
} else {
win_path.clone()
};
let injected = project_path.as_ref().map(|p| {
std::path::Path::new(p).join(".claude").join("liangjing.json").exists()
}).unwrap_or(false);
let inject_icon = if injected { "✅ 已注入" } else { "⚠️ 未注入" };
let group_str = group_id.as_deref().unwrap_or("无产品组");
let path_str = project_path.as_deref().unwrap_or("未配置路径");
out.push_str(&format!(
"- **{}** `{}`\n {} | 组: `{}` | {}\n\n",
name, id, inject_icon, group_str, path_str
));
}
out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。");
Ok(out)
}
// ── Gitea Webhook 单元测试 ────────────────────────────────────────────────────
#[cfg(test)]