use std::path::Path;
use serde_json::Value as JsonValue;
use super::common::{
clamp_limit, command_formatted_exit_code, extract_stderr_from_formatted, gh_allowed,
join_json_fields, push_bool_flag, push_extra_args_from_json, push_repo_arg,
push_trimmed_string_flag, run_gh_vec, validate_extra_args, validate_pr_body,
validate_pr_ref_token, validate_pr_title, validate_repo, write_workspace_temp_markdown,
};
use super::pr_body::build_pr_body_draft;
use super::run_ci::{
PR_CHECKS_STRUCTURED_JSON_FIELDS, finalize_structured_pr_checks, gh_pr_checks_rejects_json_flag,
};
pub fn gh_run_list(
args_json: &str,
max_output_len: usize,
allowed_commands: &[String],
working_dir: &Path,
) -> String {
if let Err(e) = gh_allowed(allowed_commands) {
return e;
}
let v = match crate::cm_tools::tools::parse_args_json(args_json) {
Ok(x) => x,
Err(e) => return e,
};
let mut argv = vec!["run".into(), "list".into()];
if let Some(r) = v.get("repo").and_then(|x| x.as_str()) {
if let Err(e) = validate_repo(r) {
return e;
}
argv.push("-R".into());
argv.push(r.trim().to_string());
}
let lim = clamp_limit(v.get("limit").and_then(|x| x.as_u64()).map(|u| u as u32));
argv.push("--limit".into());
argv.push(lim.to_string());
if let Some(arr) = v.get("fields").and_then(|x| x.as_array()) {
let fields: Vec<String> = arr
.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect();
match join_json_fields(&fields) {
Ok(j) => {
argv.push("--json".into());
argv.push(j);
}
Err(e) => return e,
}
}
if v.get("web").and_then(|x| x.as_bool()) == Some(true) {
argv.push("--web".into());
}
if let Some(arr) = v.get("extra_args").and_then(|x| x.as_array()) {
let extra: Vec<String> = arr
.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect();
if let Err(e) = validate_extra_args(&extra) {
return e;
}
argv.extend(extra);
}
run_gh_vec(argv, max_output_len, allowed_commands, working_dir)
}
fn gh_pr_diff_argv(v: &JsonValue) -> Result<Vec<String>, String> {
let num = match v.get("number").and_then(|x| x.as_u64()) {
Some(n) if n > 0 && n <= 999_999 => n.to_string(),
_ => return Err("错误:缺少或非法 number".to_string()),
};
let mut argv = vec!["pr".into(), "diff".into(), num];
push_repo_arg(v, &mut argv)?;
push_bool_flag(v, "patch", "--patch", &mut argv);
push_extra_args_from_json(v, &mut argv)?;
Ok(argv)
}
pub fn gh_pr_diff(
args_json: &str,
max_output_len: usize,
allowed_commands: &[String],
working_dir: &Path,
) -> String {
if let Err(e) = gh_allowed(allowed_commands) {
return e;
}
let v = match crate::cm_tools::tools::parse_args_json(args_json) {
Ok(x) => x,
Err(e) => return e,
};
match gh_pr_diff_argv(&v) {
Ok(argv) => run_gh_vec(argv, max_output_len, allowed_commands, working_dir),
Err(e) => e,
}
}
fn build_pr_checks_argv(v: &JsonValue, with_structured_json: bool) -> Result<Vec<String>, String> {
let mut argv = vec!["pr".into(), "checks".into()];
if let Some(r) = v.get("repo").and_then(|x| x.as_str()) {
validate_repo(r)?;
argv.push("-R".into());
argv.push(r.trim().to_string());
}
if let Some(n) = v.get("number").and_then(|x| x.as_u64()) {
if n == 0 || n > 999_999 {
return Err("错误:number 须为 1~999999 的正整数或省略".to_string());
}
argv.push(n.to_string());
}
if with_structured_json {
argv.push("--json".into());
argv.push(PR_CHECKS_STRUCTURED_JSON_FIELDS.into());
}
if let Some(arr) = v.get("extra_args").and_then(|x| x.as_array()) {
let extra: Vec<String> = arr
.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect();
validate_extra_args(&extra)?;
argv.extend(extra);
}
Ok(argv)
}
fn gh_pr_checks_table_fallback(
v: &JsonValue,
max_output_len: usize,
allowed_commands: &[String],
working_dir: &Path,
) -> String {
let argv = match build_pr_checks_argv(v, false) {
Ok(a) => a,
Err(e) => return e,
};
let table = run_gh_vec(argv, max_output_len, allowed_commands, working_dir);
format!(
"{}\n\n---\n提示:本机 `gh` 不支持 `pr checks --json`(需 GitHub CLI ≥ 2.50)。已回退为表格输出;请升级 `gh` 后再用 `structured: true`。\n",
table.trim_end()
)
}
pub fn gh_pr_checks(
args_json: &str,
max_output_len: usize,
allowed_commands: &[String],
working_dir: &Path,
) -> String {
if let Err(e) = gh_allowed(allowed_commands) {
return e;
}
let v = match crate::cm_tools::tools::parse_args_json(args_json) {
Ok(x) => x,
Err(e) => return e,
};
let structured = v.get("structured").and_then(|x| x.as_bool()) == Some(true);
let argv = match build_pr_checks_argv(&v, structured) {
Ok(a) => a,
Err(e) => return e,
};
let out = run_gh_vec(argv, max_output_len, allowed_commands, working_dir);
if !structured {
return out;
}
if gh_pr_checks_rejects_json_flag(&out) {
return gh_pr_checks_table_fallback(&v, max_output_len, allowed_commands, working_dir);
}
finalize_structured_pr_checks(out)
}
fn resolve_pr_create_body(v: &JsonValue, working_dir: &Path) -> Result<String, String> {
let auto_body = v.get("auto_body").and_then(|x| x.as_bool()).unwrap_or(true);
match v.get("body").and_then(|x| x.as_str()) {
Some(s) if !s.trim().is_empty() => Ok(s.to_string()),
_ if auto_body => {
let base = v.get("base").and_then(|x| x.as_str());
build_pr_body_draft(working_dir, base, 30, true, true)
}
_ => Ok(String::new()),
}
}
fn gh_pr_create_validate_repo_base_head(v: &JsonValue) -> Result<(), String> {
if let Some(r) = v.get("repo").and_then(|x| x.as_str()) {
validate_repo(r)?;
}
if let Some(b) = v.get("base").and_then(|x| x.as_str()) {
validate_pr_ref_token(b)?;
}
if let Some(h) = v.get("head").and_then(|x| x.as_str()) {
validate_pr_ref_token(h)?;
}
Ok(())
}
fn gh_pr_create_build_argv(
v: &JsonValue,
title: &str,
body_path_str: String,
) -> Result<Vec<String>, String> {
let mut argv = vec![
"pr".into(),
"create".into(),
"--title".into(),
title.trim().to_string(),
"--body-file".into(),
body_path_str,
];
push_repo_arg(v, &mut argv)?;
push_trimmed_string_flag(v, "base", "--base", &mut argv);
push_trimmed_string_flag(v, "head", "--head", &mut argv);
push_bool_flag(v, "draft", "--draft", &mut argv);
push_bool_flag(v, "web", "--web", &mut argv);
push_extra_args_from_json(v, &mut argv)?;
Ok(argv)
}
fn annotate_gh_pr_create_failure(formatted: String) -> String {
if command_formatted_exit_code(&formatted) == Some(0) {
return formatted;
}
let stderr = extract_stderr_from_formatted(&formatted).to_ascii_lowercase();
let hint = if stderr.contains("no commits between")
|| stderr.contains("head sha can't be blank")
|| stderr.contains("base sha can't be blank")
{
"head 分支与 base 分支之间没有提交差异,或 head 分支尚未推送到远端。请先用 `git push -u origin <head>` 推送包含至少一个提交的分支,并确认 head/base 分支名拼写无误后再重试。"
} else if stderr.contains("base ref must be a branch") {
"`base` 必须是仓库中已存在的分支(如 main/master)。请确认 base 分支名拼写后再重试。"
} else if stderr.contains("a pull request already exists") {
"该 head 分支已有关联 PR。请改用 `gh_pr_view` 查看现有 PR,而非重复创建。"
} else if stderr.contains("repository not found")
|| stderr.contains("could not resolve to a repository")
{
"仓库不存在或当前 `gh` 身份无权访问。请确认 `repo` 参数(owner/repo)拼写与 `gh auth status` 的权限。"
} else {
return formatted;
};
format!("{}\n\n---\n提示:{}\n", formatted.trim_end(), hint)
}
fn gh_pr_create_inputs(
args_json: &str,
working_dir: &Path,
) -> Result<(JsonValue, String, String), String> {
let v = crate::cm_tools::tools::parse_args_json(args_json)?;
let title = v
.get("title")
.and_then(|x| x.as_str())
.ok_or_else(|| "错误:缺少 title".to_string())?
.to_string();
validate_pr_title(&title)?;
let body_str = resolve_pr_create_body(&v, working_dir)?;
validate_pr_body(&body_str)?;
gh_pr_create_validate_repo_base_head(&v)?;
Ok((v, title, body_str))
}
fn write_pr_create_body_temp(
working_dir: &Path,
body: &[u8],
) -> Result<(tempfile::TempDir, String), String> {
match write_workspace_temp_markdown(working_dir, "crabmate_pr_body.md", body, "PR 正文") {
Ok(x) => Ok(x),
Err(e) if e.contains("临时文件路径非 UTF-8") => {
Err("错误:临时文件路径非 UTF-8".to_string())
}
Err(e) => Err(e),
}
}
pub fn gh_pr_create(
args_json: &str,
max_output_len: usize,
allowed_commands: &[String],
working_dir: &Path,
) -> String {
if let Err(e) = gh_allowed(allowed_commands) {
return e;
}
let (v, title, body_str) = match gh_pr_create_inputs(args_json, working_dir) {
Ok(x) => x,
Err(e) => return e,
};
let (dir, body_path_str) = match write_pr_create_body_temp(working_dir, body_str.as_bytes()) {
Ok(x) => x,
Err(e) => return e,
};
let argv = match gh_pr_create_build_argv(&v, &title, body_path_str) {
Ok(a) => a,
Err(e) => return e,
};
let out = run_gh_vec(argv, max_output_len, allowed_commands, working_dir);
drop(dir);
annotate_gh_pr_create_failure(out)
}
#[cfg(test)]
mod tests {
use super::annotate_gh_pr_create_failure;
fn gh_create_err(stderr: &str) -> String {
format!(
"命令:gh pr create --title t --body-file /tmp/b.md --base main --head feat/x\n退出码:1\n标准错误:\n{stderr}"
)
}
#[test]
fn annotate_no_commits_between_suggests_push() {
let raw = gh_create_err(
"pull request create failed: GraphQL: Head sha can't be blank, Base sha can't be blank, \
No commits between main and feat/avx-example, Base ref must be a branch (createPullRequest)",
);
let out = annotate_gh_pr_create_failure(raw);
assert!(out.contains("push -u origin"), "{}", out);
assert!(out.contains("提示"), "{}", out);
}
#[test]
fn annotate_base_ref_must_be_branch() {
let raw = gh_create_err("GraphQL: Base ref must be a branch (createPullRequest)");
let out = annotate_gh_pr_create_failure(raw);
assert!(out.contains("必须") && out.contains("base"), "{}", out);
}
#[test]
fn annotate_pr_already_exists_points_to_view() {
let raw = gh_create_err("a pull request already exists for feat/x (createPullRequest)");
let out = annotate_gh_pr_create_failure(raw);
assert!(out.contains("gh_pr_view"), "{}", out);
}
#[test]
fn annotate_repo_not_found() {
let raw =
gh_create_err("GraphQL: Could not resolve to a Repository with the name 'no/such'");
let out = annotate_gh_pr_create_failure(raw);
assert!(out.contains("仓库"), "{}", out);
}
#[test]
fn annotate_bare_not_found_is_left_untouched() {
let raw = gh_create_err("GraphQL: Some other thing not found (createPullRequest)");
let out = annotate_gh_pr_create_failure(raw.clone());
assert_eq!(out, raw);
}
#[test]
fn annotate_keeps_success_output_untouched() {
let raw =
"命令:gh pr create --title t\n退出码:0\n标准输出:\nhttps://github.com/o/r/pull/1\n";
let out = annotate_gh_pr_create_failure(raw.to_string());
assert_eq!(out, raw);
}
#[test]
fn annotate_leaves_unrecognized_failure_untouched() {
let raw = gh_create_err("some unrelated error");
let out = annotate_gh_pr_create_failure(raw.clone());
assert_eq!(out, raw);
}
}