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:
parent
dccca234b5
commit
def53d739c
28
.gitea/workflows/pr-check.yml
Normal file
28
.gitea/workflows/pr-check.yml
Normal 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
|
||||
@ -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 已存在)时自动查找并返回现有 PR,already_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_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,
|
||||
"pr_number": pr_number,
|
||||
"pr_url": pr_url,
|
||||
"head": head,
|
||||
"base": base,
|
||||
"title": pr_title,
|
||||
"already_exists": already_exists,
|
||||
})).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
|
||||
@ -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)]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user