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)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通过炼境存储的凭证为项目创建 PR,返回 JSON 字符串(含 pr_url / pr_number)
|
/// 通过炼境存储的凭证为项目创建 PR,返回 JSON 字符串(含 pr_url / pr_number / already_exists)。
|
||||||
///
|
/// 409 冲突(PR 已存在)时自动查找并返回现有 PR,already_exists=true。
|
||||||
/// 设计意图:MCP 工具调用此函数,避免 Claude 在 bash 里提取 token 再 curl(会被权限系统拒绝)。
|
|
||||||
pub fn gitea_api_create_pull_request(
|
pub fn gitea_api_create_pull_request(
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
head: &str,
|
head: &str,
|
||||||
@ -677,28 +676,46 @@ pub fn gitea_api_create_pull_request(
|
|||||||
|
|
||||||
let (base_url, token) = instance_auth(&conn, &instance_id)?;
|
let (base_url, token) = instance_auth(&conn, &instance_id)?;
|
||||||
|
|
||||||
let url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls");
|
let create_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls");
|
||||||
let resp = ureq::post(&url)
|
let (json, already_exists) = match ureq::post(&create_url)
|
||||||
.set("Authorization", &format!("token {token}"))
|
.set("Authorization", &format!("token {token}"))
|
||||||
.set("Content-Type", "application/json")
|
.set("Content-Type", "application/json")
|
||||||
.send_json(serde_json::json!({
|
.send_json(serde_json::json!({ "head": head, "base": base, "title": title, "body": body }))
|
||||||
"head": head,
|
{
|
||||||
"base": base,
|
Ok(resp) => (resp.into_json::<serde_json::Value>().map_err(|e| format!("解析响应失败: {e}"))?, false),
|
||||||
"title": title,
|
Err(ureq::Error::Status(409, _)) => {
|
||||||
"body": body,
|
// PR 已存在,列出开放 PR 找到匹配的分支
|
||||||
}))
|
let list_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls?state=open&limit=30");
|
||||||
.map_err(|e| api_err("创建 PR 失败", e))?;
|
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_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!({
|
serde_json::to_string(&serde_json::json!({
|
||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"pr_url": pr_url,
|
"pr_url": pr_url,
|
||||||
"head": head,
|
"head": head,
|
||||||
"base": base,
|
"base": base,
|
||||||
"title": title,
|
"title": pr_title,
|
||||||
|
"already_exists": already_exists,
|
||||||
})).map_err(|e| e.to_string())
|
})).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -670,6 +670,29 @@ fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
"required": ["project_id", "workflow_type"]
|
"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("");
|
let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
crate::commands::cicd::distribute_workflow_for_project(pid, wt)
|
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)),
|
_ => Err(format!("未知工具: {}", tool_name)),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@ -1219,6 +1270,61 @@ fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result<
|
|||||||
Ok(md)
|
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 单元测试 ────────────────────────────────────────────────────
|
// ── Gitea Webhook 单元测试 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user