pub(crate) mod branch;
pub(crate) mod labels;
pub(crate) mod runner;
pub(crate) mod system;
use serde::Deserialize;
use system::{GhTicketSystem, Issue, TicketSystem, TicketSystemKind, not_yet_supported};
use runner::{CommandRunner, RealCommandRunner};
pub(crate) fn parse_issue_number(raw: &str) -> anyhow::Result<u64> {
let trimmed = raw.trim().trim_start_matches('#').trim();
let n: u64 = trimmed.parse().map_err(|_| {
anyhow::anyhow!(
"invalid issue reference `{raw}` — expected a number like `1232` or `#1232`"
)
})?;
if n == 0 {
anyhow::bail!("invalid issue reference `{raw}` — issue numbers start at 1");
}
Ok(n)
}
pub(crate) fn build_task(issue: &Issue, branch: &str, base_branch: &str) -> String {
format!(
"Address issue #{number}: {title}\n\n\
Issue body:\n{body}\n\n\
Workflow requirements:\n\
- Work on branch `{branch}` (create it off the default branch `{base_branch}`).\n\
- Implement the change described in the issue.\n\
- Commit with a message referencing the issue and ending in `Closes #{number}`.\n\
- Open a pull request linking the issue so a squash-merge closes it.\n",
number = issue.number,
title = issue.title,
body = if issue.body.trim().is_empty() {
"(no body provided)"
} else {
issue.body.trim()
},
branch = branch,
base_branch = base_branch,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RepoCoordinates {
url: String,
default_branch: String,
}
fn resolve_repo<R: CommandRunner>(runner: &R) -> anyhow::Result<RepoCoordinates> {
let url = runner
.run("gh", &["repo", "view", "--json", "url", "--jq", ".url"])?
.ok_or_stderr("gh repo view")?;
if url.is_empty() {
anyhow::bail!(
"could not resolve the current repository via `gh repo view` — run inside a GitHub checkout"
);
}
let default_branch = runner
.run(
"gh",
&[
"repo",
"view",
"--json",
"defaultBranchRef",
"--jq",
".defaultBranchRef.name",
],
)?
.ok_or_stderr("gh repo view")?;
let default_branch = if default_branch.is_empty() {
"main".to_string()
} else {
default_branch
};
Ok(RepoCoordinates {
url,
default_branch,
})
}
pub(crate) async fn ticket(
client: &reqwest::Client,
url: &str,
issue_ref: String,
system: TicketSystemKind,
notes: Vec<String>,
runtime: trusty_mpm::runtime::RuntimeKind,
) -> anyhow::Result<()> {
let issue_number = parse_issue_number(&issue_ref)?;
let runner = RealCommandRunner;
let backend = match system {
TicketSystemKind::Gh => GhTicketSystem::new(RealCommandRunner),
TicketSystemKind::Jira => return Err(not_yet_supported("jira")),
TicketSystemKind::Linear => return Err(not_yet_supported("linear")),
};
let issue = backend.validate(issue_number)?;
println!(
"validated issue #{} via {}: {}",
issue.number,
backend.name(),
issue.title
);
for note in ¬es {
backend.comment(issue_number, note)?;
println!("posted note as comment on #{issue_number}");
}
let branch = issue.branch_name();
println!("ticket branch: {branch}");
let repo = resolve_repo(&runner)?;
let task = build_task(&issue, &branch, &repo.default_branch);
spawn_managed(
client,
url,
&repo.url,
&repo.default_branch,
&branch,
&task,
runtime,
)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn spawn_managed(
client: &reqwest::Client,
url: &str,
repo_url: &str,
base_ref: &str,
branch: &str,
task: &str,
runtime: trusty_mpm::runtime::RuntimeKind,
) -> anyhow::Result<()> {
#[derive(Deserialize)]
struct SpawnResp {
id: String,
name: String,
state: String,
attach_cmd: String,
#[serde(default)]
runtime: String,
}
let resp: SpawnResp = client
.post(format!("{url}/api/v1/sessions/managed"))
.json(&serde_json::json!({
"repo_url": repo_url,
"ref": base_ref,
"task": task,
"name_hint": branch,
"runtime": runtime.as_str(),
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
println!(
"spawned {} ({}) [{}] runtime={}",
resp.name, resp.id, resp.state, resp.runtime
);
println!(" task drives branch `{branch}` → PR (Closes the issue on merge)");
println!(" attach: {}", resp.attach_cmd);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use runner::CommandOutput;
use std::cell::RefCell;
struct FakeRunner {
outputs: RefCell<Vec<CommandOutput>>,
}
impl FakeRunner {
fn new(outputs: Vec<CommandOutput>) -> Self {
Self {
outputs: RefCell::new(outputs),
}
}
}
impl CommandRunner for FakeRunner {
fn run(&self, _program: &str, _args: &[&str]) -> anyhow::Result<CommandOutput> {
let mut outs = self.outputs.borrow_mut();
if outs.is_empty() {
anyhow::bail!("FakeRunner exhausted")
}
Ok(outs.remove(0))
}
}
fn ok_out(stdout: &str) -> CommandOutput {
CommandOutput {
success: true,
stdout: stdout.to_string(),
stderr: String::new(),
}
}
#[test]
fn parse_issue_number_plain() {
assert_eq!(parse_issue_number("1232").unwrap(), 1232);
}
#[test]
fn parse_issue_number_hash() {
assert_eq!(parse_issue_number("#1232").unwrap(), 1232);
assert_eq!(parse_issue_number(" #42 ").unwrap(), 42);
}
#[test]
fn parse_issue_number_rejects_nonnumeric() {
assert!(parse_issue_number("abc").is_err());
assert!(parse_issue_number("#").is_err());
assert!(parse_issue_number("12a").is_err());
}
#[test]
fn parse_issue_number_rejects_zero() {
assert!(parse_issue_number("0").is_err());
assert!(parse_issue_number("#0").is_err());
}
#[test]
fn build_task_includes_branch_and_close() {
let issue = Issue {
number: 1232,
title: "Add the thing".to_string(),
body: "do the thing properly".to_string(),
labels: vec!["enhancement".to_string()],
assignees: vec![],
open: true,
};
let task = build_task(&issue, "feat/1232-add-the-thing", "main");
assert!(task.contains("issue #1232"));
assert!(task.contains("Add the thing"));
assert!(task.contains("do the thing properly"));
assert!(task.contains("feat/1232-add-the-thing"));
assert!(task.contains("Closes #1232"));
assert!(task.contains("pull request"));
}
#[test]
fn build_task_handles_empty_body() {
let issue = Issue {
number: 7,
title: "Fix it".to_string(),
body: " ".to_string(),
labels: vec![],
assignees: vec![],
open: true,
};
let task = build_task(&issue, "feat/7-fix-it", "main");
assert!(task.contains("(no body provided)"));
}
#[test]
fn build_task_names_non_main_base_branch() {
let issue = Issue {
number: 9,
title: "Do thing".to_string(),
body: "body".to_string(),
labels: vec![],
assignees: vec![],
open: true,
};
let task = build_task(&issue, "feat/9-do-thing", "master");
assert!(
task.contains("default branch `master`"),
"task did not name the master base branch: {task}"
);
assert!(
!task.contains("`main`"),
"task wrongly mentioned main: {task}"
);
}
#[test]
fn resolve_repo_threads_default_branch() {
let runner = FakeRunner::new(vec![
ok_out("https://github.com/acme/widget"),
ok_out("master"),
]);
let repo = resolve_repo(&runner).expect("should resolve");
assert_eq!(repo.url, "https://github.com/acme/widget");
assert_eq!(repo.default_branch, "master");
}
#[test]
fn resolve_repo_falls_back_to_main_on_empty_branch() {
let runner = FakeRunner::new(vec![ok_out("https://github.com/acme/widget"), ok_out("")]);
let repo = resolve_repo(&runner).expect("should resolve");
assert_eq!(repo.default_branch, "main");
}
#[test]
fn resolve_repo_empty_url_errors() {
let runner = FakeRunner::new(vec![ok_out("")]);
let err = resolve_repo(&runner).unwrap_err().to_string();
assert!(
err.contains("could not resolve the current repository"),
"expected actionable empty-URL error, got: {err}"
);
assert!(
err.contains("GitHub checkout"),
"expected checkout hint, got: {err}"
);
}
}