use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::{anyhow, bail, Context, Result};
use serde::Serialize;
use crate::{
config::AppConfig,
run_once::{run_once, RunFinalStatus, RunOnceRequest, RunOnceResponse},
types::TrustLevel,
};
pub const DEFAULT_SOLVE_AGENT_ID: &str = "github-solve";
pub const DEFAULT_SOLVE_TEMPLATE_ID: &str = "holon-github-solve";
#[derive(Debug, Clone)]
pub struct SolveRequest {
pub target_ref: String,
pub repo: Option<String>,
pub base: Option<String>,
pub goal: Option<String>,
pub role: Option<String>,
pub agent_id: Option<String>,
pub template: Option<String>,
pub max_turns: Option<u64>,
pub trust: TrustLevel,
pub json: bool,
pub workspace_root: Option<PathBuf>,
pub cwd: Option<PathBuf>,
pub input_dir: Option<PathBuf>,
pub output_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitHubTarget {
pub repo: String,
pub number: u64,
pub kind: GitHubTargetKind,
pub url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GitHubTargetKind {
IssueOrPullRequest,
Issue,
PullRequest,
}
#[derive(Debug, Serialize)]
struct SolveManifest<'a> {
provider: &'static str,
status: &'a str,
outcome: &'a str,
target_ref: &'a str,
target: Option<&'a GitHubTarget>,
run_json: Option<String>,
summary: Option<String>,
}
pub async fn run_solve(config: AppConfig, request: SolveRequest) -> Result<RunOnceResponse> {
let output_dir = prepare_output_dir(request.output_dir.as_deref())?;
let input_dir = prepare_input_dir(request.input_dir.as_deref(), &output_dir)?;
export_solve_env(&input_dir, &output_dir);
let target = parse_github_target(&request.target_ref, request.repo.as_deref())?;
write_input_metadata(&input_dir, &request, target.as_ref())?;
let prompt = build_solve_prompt(&request, target.as_ref(), &output_dir);
let agent_id = request
.agent_id
.clone()
.unwrap_or_else(|| DEFAULT_SOLVE_AGENT_ID.to_string());
let template = request
.template
.clone()
.unwrap_or_else(|| DEFAULT_SOLVE_TEMPLATE_ID.to_string());
let run_request = RunOnceRequest {
text: prompt,
trust: request.trust.clone(),
agent_id: Some(agent_id.clone()),
create_agent: true,
template: Some(template),
max_turns: request.max_turns,
wait_for_tasks: true,
workspace_root: request.workspace_root.clone(),
cwd: request.cwd.clone(),
};
let response = match run_once(config.clone(), run_request.clone()).await {
Ok(response) => response,
Err(err) if is_agent_template_already_initialized_error(&err) => {
let retry = RunOnceRequest {
create_agent: false,
template: None,
..run_request
};
run_once(config, retry).await?
}
Err(err) => return Err(err),
};
write_run_artifacts(&output_dir, &request, target.as_ref(), &response)?;
Ok(response)
}
fn is_agent_template_already_initialized_error(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
let message = cause.to_string();
message.contains(
"already exists; template initialization only applies when creating a new agent",
)
})
}
fn prepare_output_dir(output_dir: Option<&Path>) -> Result<PathBuf> {
let path = output_dir.map(Path::to_path_buf).unwrap_or_else(|| {
std::env::temp_dir().join(format!("holon-output-{}", uuid::Uuid::new_v4()))
});
fs::create_dir_all(&path)
.with_context(|| format!("failed to create output directory {}", path.display()))?;
Ok(path)
}
fn prepare_input_dir(input_dir: Option<&Path>, output_dir: &Path) -> Result<PathBuf> {
let path = input_dir
.map(Path::to_path_buf)
.unwrap_or_else(|| output_dir.join("github-context"));
fs::create_dir_all(&path)
.with_context(|| format!("failed to create input directory {}", path.display()))?;
Ok(path)
}
fn export_solve_env(input_dir: &Path, output_dir: &Path) {
std::env::set_var("GITHUB_INPUT_DIR", input_dir);
std::env::set_var("GITHUB_CONTEXT_DIR", input_dir);
std::env::set_var("GITHUB_OUTPUT_DIR", output_dir);
}
pub fn parse_github_target(
target_ref: &str,
repo_hint: Option<&str>,
) -> Result<Option<GitHubTarget>> {
let trimmed = target_ref.trim();
if trimmed.is_empty() {
bail!("solve ref must not be empty");
}
if let Some((repo, number, kind)) = parse_github_url(trimmed)? {
return Ok(Some(GitHubTarget {
repo: repo.clone(),
number,
kind,
url: target_url(&repo, number, kind),
}));
}
if let Some(number) = trimmed.strip_prefix('#') {
let repo = repo_hint.ok_or_else(|| anyhow!("numeric ref requires --repo"))?;
validate_repo(repo)?;
let number = parse_number(number)?;
return Ok(Some(GitHubTarget {
repo: repo.to_string(),
number,
kind: GitHubTargetKind::IssueOrPullRequest,
url: target_url(repo, number, GitHubTargetKind::IssueOrPullRequest),
}));
}
if let Some((repo, number)) = trimmed.split_once('#') {
let repo = repo.trim();
validate_repo(repo)?;
let number = parse_number(number)?;
return Ok(Some(GitHubTarget {
repo: repo.to_string(),
number,
kind: GitHubTargetKind::IssueOrPullRequest,
url: target_url(repo, number, GitHubTargetKind::IssueOrPullRequest),
}));
}
Ok(None)
}
fn parse_github_url(raw: &str) -> Result<Option<(String, u64, GitHubTargetKind)>> {
let Ok(url) = reqwest::Url::parse(raw) else {
return Ok(None);
};
if url.host_str() != Some("github.com") {
return Ok(None);
}
let segments = url
.path_segments()
.map(|segments| segments.collect::<Vec<_>>())
.unwrap_or_default();
if segments.len() < 4 {
return Ok(None);
}
let repo = format!("{}/{}", segments[0], segments[1]);
validate_repo(&repo)?;
let kind = match segments[2] {
"issues" => GitHubTargetKind::Issue,
"pull" => GitHubTargetKind::PullRequest,
_ => return Ok(None),
};
Ok(Some((repo, parse_number(segments[3])?, kind)))
}
fn validate_repo(repo: &str) -> Result<()> {
let parts = repo.split('/').collect::<Vec<_>>();
if parts.len() != 2 || parts.iter().any(|part| part.trim().is_empty()) {
bail!("repo must be in owner/repo format: {repo}");
}
Ok(())
}
fn parse_number(raw: &str) -> Result<u64> {
raw.trim()
.parse::<u64>()
.with_context(|| format!("invalid GitHub issue/PR number: {raw}"))
}
fn target_url(repo: &str, number: u64, kind: GitHubTargetKind) -> String {
let path = match kind {
GitHubTargetKind::IssueOrPullRequest | GitHubTargetKind::Issue => "issues",
GitHubTargetKind::PullRequest => "pull",
};
format!("https://github.com/{repo}/{path}/{number}")
}
pub fn build_solve_prompt(
request: &SolveRequest,
target: Option<&GitHubTarget>,
output_dir: &Path,
) -> String {
let mut sections = Vec::new();
sections.push(
"You are running Holon solve, a GitHub task preset built on top of holon run.".to_string(),
);
match target {
Some(target) => {
sections.push(format!(
"Target: {} #{} ({})\nURL: {}",
target.repo,
target.number,
match target.kind {
GitHubTargetKind::IssueOrPullRequest => "issue_or_pull_request",
GitHubTargetKind::Issue => "issue",
GitHubTargetKind::PullRequest => "pull_request",
},
target.url
));
}
None => sections.push(format!("Target ref: {}", request.target_ref)),
}
if let Some(base) = request.base.as_deref().filter(|value| !value.is_empty()) {
sections.push(format!("Base branch hint: {base}"));
}
if let Some(role) = request.role.as_deref().filter(|value| !value.is_empty()) {
sections.push(format!("Role hint: {role}"));
}
if let Some(goal) = request.goal.as_deref().filter(|value| !value.is_empty()) {
sections.push(format!("User goal:\n{goal}"));
} else {
sections.push(default_goal(target));
}
sections.push(format!(
"Output contract:\n- Write a concise human summary to {}/summary.md.\n- Write machine-readable execution status to {}/manifest.json.\n- Include any PR URL, comment URL, branch, commit SHA, verification commands, and residual blockers when available.",
output_dir.display(),
output_dir.display()
));
sections.push(
"GitHub operating rules:\n- Use GITHUB_TOKEN or GH_TOKEN when publishing through gh.\n- The repository checkout is already prepared by the caller; do not clone by default.\n- If code changes are required, create or reuse an appropriate branch, commit intentionally, push, and create or update a PR yourself.\n- If this is a review-only task, publish one structured review or comment only when the requested skill workflow calls for it.\n- Prefer the github-issue-solve, github-pr-fix, github-review, and ghx skills when their descriptions match the target."
.to_string(),
);
if should_include_review_publish_guardrails(request, target) {
sections.push(
"Review publishing guardrails:\n- Treat PR review/comment publishing as a single-shot external side effect.\n- For a review-only goal, choose exactly one publish surface: one PR review or one PR comment, not both.\n- Before publishing, read the current PR head SHA and existing reviews/comments by Holon/Holonbot for that same head. If an equivalent review/comment already exists, do not publish again; record the existing URL/status in the output artifacts.\n- After any review/comment publish command succeeds, stop all other publish paths immediately. Do not run fallback `gh pr review`, `gh api .../reviews`, or issue-comment publish commands after a successful primary publish.\n- If a publish command result is ambiguous, verify existing reviews/comments for the same head SHA before retrying. Retry only when no Holon/Holonbot review or comment for that head exists."
.to_string(),
);
}
sections.join("\n\n")
}
fn should_include_review_publish_guardrails(
request: &SolveRequest,
target: Option<&GitHubTarget>,
) -> bool {
matches!(
target.map(|target| target.kind),
Some(GitHubTargetKind::PullRequest)
) || request
.goal
.as_deref()
.map(goal_mentions_review)
.unwrap_or(false)
}
fn goal_mentions_review(goal: &str) -> bool {
goal.split(|ch: char| !ch.is_ascii_alphanumeric())
.any(|token| {
matches!(
token.to_ascii_lowercase().as_str(),
"review" | "reviews" | "reviewing"
)
})
}
fn default_goal(target: Option<&GitHubTarget>) -> String {
match target.map(|target| target.kind) {
Some(GitHubTargetKind::PullRequest) => {
"Default goal: fix or review the target pull request according to the trigger context.".to_string()
}
Some(GitHubTargetKind::Issue) => {
"Default goal: solve the target issue end to end and publish the result.".to_string()
}
_ => {
"Default goal: inspect the target, determine whether it is an issue or pull request, then choose the appropriate GitHub skill workflow.".to_string()
}
}
}
fn write_input_metadata(
input_dir: &Path,
request: &SolveRequest,
target: Option<&GitHubTarget>,
) -> Result<()> {
#[derive(Serialize)]
struct InputMetadata<'a> {
target_ref: &'a str,
target: Option<&'a GitHubTarget>,
repo_hint: Option<&'a str>,
base: Option<&'a str>,
role: Option<&'a str>,
goal: Option<&'a str>,
}
let metadata = InputMetadata {
target_ref: &request.target_ref,
target,
repo_hint: request.repo.as_deref(),
base: request.base.as_deref(),
role: request.role.as_deref(),
goal: request.goal.as_deref(),
};
let content = serde_json::to_vec_pretty(&metadata)?;
fs::write(input_dir.join("solve.json"), content)
.with_context(|| format!("failed to write {}", input_dir.join("solve.json").display()))
}
fn write_run_artifacts(
output_dir: &Path,
request: &SolveRequest,
target: Option<&GitHubTarget>,
response: &RunOnceResponse,
) -> Result<()> {
let run_json_path = output_dir.join("run.json");
fs::write(&run_json_path, serde_json::to_vec_pretty(response)?)
.with_context(|| format!("failed to write {}", run_json_path.display()))?;
let summary_path = output_dir.join("summary.md");
if !summary_path.exists() {
fs::write(&summary_path, response.render_text())
.with_context(|| format!("failed to write {}", summary_path.display()))?;
}
let manifest_path = output_dir.join("manifest.json");
if !manifest_path.exists() {
let status = match response.final_status {
RunFinalStatus::Completed => "completed",
RunFinalStatus::Waiting => "waiting",
RunFinalStatus::Failed => "failed",
RunFinalStatus::MaxTurnsExceeded => "max_turns_exceeded",
};
let outcome = if response.final_status == RunFinalStatus::Completed {
"success"
} else {
"incomplete"
};
let manifest = SolveManifest {
provider: "holon-solve",
status,
outcome,
target_ref: &request.target_ref,
target,
run_json: Some(run_json_path.display().to_string()),
summary: Some(summary_path.display().to_string()),
};
fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)
.with_context(|| format!("failed to write {}", manifest_path.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn request(target_ref: &str) -> SolveRequest {
SolveRequest {
target_ref: target_ref.to_string(),
repo: None,
base: Some("main".into()),
goal: None,
role: None,
agent_id: None,
template: None,
max_turns: Some(1),
trust: TrustLevel::TrustedOperator,
json: true,
workspace_root: None,
cwd: None,
input_dir: None,
output_dir: None,
}
}
#[test]
fn parse_owner_repo_number_ref() {
let target = parse_github_target("holon-run/holon#123", None)
.unwrap()
.unwrap();
assert_eq!(target.repo, "holon-run/holon");
assert_eq!(target.number, 123);
assert_eq!(target.kind, GitHubTargetKind::IssueOrPullRequest);
}
#[test]
fn parse_github_pull_url() {
let target = parse_github_target("https://github.com/holon-run/holon/pull/753", None)
.unwrap()
.unwrap();
assert_eq!(target.repo, "holon-run/holon");
assert_eq!(target.number, 753);
assert_eq!(target.kind, GitHubTargetKind::PullRequest);
assert_eq!(target.url, "https://github.com/holon-run/holon/pull/753");
}
#[test]
fn build_prompt_contains_github_skill_contract() {
let request = request("holon-run/holon#123");
let target = parse_github_target(&request.target_ref, None).unwrap();
let prompt = build_solve_prompt(&request, target.as_ref(), Path::new("/tmp/out"));
assert!(prompt.contains("github-issue-solve"));
assert!(prompt.contains("github-pr-fix"));
assert!(prompt.contains("github-review"));
assert!(prompt.contains("/tmp/out/manifest.json"));
}
#[test]
fn build_prompt_adds_review_publish_guardrails_for_review_goals() {
let mut request = request("https://github.com/holon-run/holon-test/issues/52");
request.goal = Some(
"Review the target pull request only. Publish one concise review or PR comment.".into(),
);
let target = parse_github_target(&request.target_ref, None).unwrap();
let prompt = build_solve_prompt(&request, target.as_ref(), Path::new("/tmp/out"));
assert!(prompt.contains("Review publishing guardrails"));
assert!(prompt.contains("single-shot external side effect"));
assert!(prompt.contains("one PR review or one PR comment, not both"));
assert!(prompt.contains("After any review/comment publish command succeeds"));
assert!(prompt.contains("verify existing reviews/comments for the same head SHA"));
}
#[test]
fn review_publish_guardrails_do_not_match_preview_text() {
let mut request = request("https://github.com/holon-run/holon-test/issues/52");
request.goal = Some("Generate a preview artifact for the target issue.".into());
let target = parse_github_target(&request.target_ref, None).unwrap();
let prompt = build_solve_prompt(&request, target.as_ref(), Path::new("/tmp/out"));
assert!(!prompt.contains("Review publishing guardrails"));
}
#[test]
fn agent_template_retry_only_matches_specific_create_agent_error() {
let expected = anyhow!(
"agent github-solve already exists; template initialization only applies when creating a new agent"
);
assert!(is_agent_template_already_initialized_error(&expected));
let unrelated = anyhow!("target file already exists: README.md");
assert!(!is_agent_template_already_initialized_error(&unrelated));
}
}