use clap::ValueEnum;
use serde::Deserialize;
use super::branch::derive_branch_name;
use super::labels::{
AssigneeTarget, RepoLabel, gh_add_label, gh_create_label, gh_list_repo_labels, gh_remove_label,
gh_set_assignee, gh_swap_labels,
};
use super::runner::CommandRunner;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub(crate) enum TicketSystemKind {
#[default]
Gh,
Jira,
Linear,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Issue {
pub(crate) number: u64,
pub(crate) title: String,
pub(crate) body: String,
pub(crate) labels: Vec<String>,
pub(crate) assignees: Vec<String>,
pub(crate) open: bool,
}
impl Issue {
pub(crate) fn branch_name(&self) -> String {
derive_branch_name(self.number, &self.title, &self.labels)
}
}
pub(crate) trait TicketSystem {
fn name(&self) -> &'static str;
fn validate(&self, issue_number: u64) -> anyhow::Result<Issue>;
fn comment(&self, issue_number: u64, body: &str) -> anyhow::Result<()>;
fn list_repo_labels(&self) -> anyhow::Result<Vec<RepoLabel>> {
anyhow::bail!("list_repo_labels not supported for this ticket system")
}
fn create_label(&self, _label: &RepoLabel) -> anyhow::Result<()> {
anyhow::bail!("create_label not supported for this ticket system")
}
fn add_label(&self, _issue: u64, _label: &str) -> anyhow::Result<()> {
anyhow::bail!("add_label not supported for this ticket system")
}
fn remove_label(&self, _issue: u64, _label: &str) -> anyhow::Result<()> {
anyhow::bail!("remove_label not supported for this ticket system")
}
fn swap_labels(&self, _issue: u64, _add: &str, _remove: &str) -> anyhow::Result<()> {
anyhow::bail!("swap_labels not supported for this ticket system")
}
fn set_assignee(
&self,
_issue: u64,
_who: &AssigneeTarget,
_current: &[String],
) -> anyhow::Result<()> {
anyhow::bail!("set_assignee not supported for this ticket system")
}
}
pub(crate) struct GhTicketSystem<R: CommandRunner> {
runner: R,
}
impl<R: CommandRunner> GhTicketSystem<R> {
pub(crate) fn new(runner: R) -> Self {
Self { runner }
}
}
#[derive(Debug, Deserialize)]
struct GhIssueJson {
number: u64,
#[serde(default)]
title: String,
#[serde(default)]
body: String,
#[serde(default)]
state: String,
#[serde(default)]
labels: Vec<GhLabel>,
#[serde(default)]
assignees: Vec<GhAssignee>,
}
#[derive(Debug, Deserialize)]
struct GhAssignee {
#[serde(default)]
login: String,
}
#[derive(Debug, Deserialize)]
struct GhLabel {
#[serde(default)]
name: String,
}
impl<R: CommandRunner> TicketSystem for GhTicketSystem<R> {
fn name(&self) -> &'static str {
"gh"
}
fn validate(&self, issue_number: u64) -> anyhow::Result<Issue> {
let number = issue_number.to_string();
let out = self.runner.run(
"gh",
&[
"issue",
"view",
&number,
"--json",
"number,title,body,state,labels,assignees",
],
)?;
if !out.success {
let detail = out.stderr.trim();
anyhow::bail!(
"issue #{issue_number} could not be resolved via `gh`{} \
— check the number and that `gh auth status` is logged in",
if detail.is_empty() {
String::new()
} else {
format!(" ({detail})")
}
);
}
let parsed: GhIssueJson = serde_json::from_str(out.stdout.trim()).map_err(|e| {
anyhow::anyhow!("failed to parse `gh issue view` JSON for #{issue_number}: {e}")
})?;
let open = parsed.state.eq_ignore_ascii_case("OPEN");
if !open {
anyhow::bail!(
"issue #{issue_number} is {} — refusing to start work on a non-open issue",
if parsed.state.is_empty() {
"not open".to_string()
} else {
parsed.state.to_lowercase()
}
);
}
Ok(Issue {
number: parsed.number,
title: parsed.title,
body: parsed.body,
labels: parsed.labels.into_iter().map(|l| l.name).collect(),
assignees: parsed.assignees.into_iter().map(|a| a.login).collect(),
open,
})
}
fn comment(&self, issue_number: u64, body: &str) -> anyhow::Result<()> {
let number = issue_number.to_string();
let out = self
.runner
.run("gh", &["issue", "comment", &number, "--body", body])?;
out.ok_or_stderr("gh issue comment")?;
Ok(())
}
fn list_repo_labels(&self) -> anyhow::Result<Vec<RepoLabel>> {
gh_list_repo_labels(&self.runner)
}
fn create_label(&self, label: &RepoLabel) -> anyhow::Result<()> {
gh_create_label(&self.runner, label)
}
fn add_label(&self, issue: u64, label: &str) -> anyhow::Result<()> {
gh_add_label(&self.runner, issue, label)
}
fn remove_label(&self, issue: u64, label: &str) -> anyhow::Result<()> {
gh_remove_label(&self.runner, issue, label)
}
fn swap_labels(&self, issue: u64, add: &str, remove: &str) -> anyhow::Result<()> {
gh_swap_labels(&self.runner, issue, add, remove)
}
fn set_assignee(
&self,
issue: u64,
who: &AssigneeTarget,
current: &[String],
) -> anyhow::Result<()> {
gh_set_assignee(&self.runner, issue, who, current)
}
}
pub(crate) fn not_yet_supported(system: &str) -> anyhow::Error {
anyhow::anyhow!(
"ticket system `{system}` is not yet supported — only `gh` (GitHub) is \
implemented today; JIRA and Linear are planned"
)
}
#[cfg(test)]
mod tests {
use super::super::runner::CommandOutput;
use super::*;
use std::cell::RefCell;
struct FakeRunner {
outputs: RefCell<Vec<CommandOutput>>,
calls: RefCell<Vec<(String, Vec<String>)>>,
}
impl FakeRunner {
fn new(outputs: Vec<CommandOutput>) -> Self {
Self {
outputs: RefCell::new(outputs),
calls: RefCell::new(Vec::new()),
}
}
}
impl CommandRunner for FakeRunner {
fn run(&self, program: &str, args: &[&str]) -> anyhow::Result<CommandOutput> {
self.calls.borrow_mut().push((
program.to_string(),
args.iter().map(|a| a.to_string()).collect(),
));
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(),
}
}
fn fail_out(stderr: &str) -> CommandOutput {
CommandOutput {
success: false,
stdout: String::new(),
stderr: stderr.to_string(),
}
}
#[test]
fn ticket_system_kind_default_is_gh() {
assert_eq!(TicketSystemKind::default(), TicketSystemKind::Gh);
}
#[test]
fn issue_branch_name() {
let issue = Issue {
number: 1232,
title: "Add the thing".to_string(),
body: String::new(),
labels: vec!["enhancement".to_string()],
assignees: vec![],
open: true,
};
assert_eq!(issue.branch_name(), "feat/1232-add-the-thing");
}
#[test]
fn gh_validate_parses_open_issue() {
let json = r#"{"number":1232,"title":"Add the thing","body":"do it","state":"OPEN","labels":[{"name":"bug"}]}"#;
let sys = GhTicketSystem::new(FakeRunner::new(vec![ok_out(json)]));
let issue = sys.validate(1232).expect("should validate");
assert_eq!(issue.number, 1232);
assert_eq!(issue.title, "Add the thing");
assert_eq!(issue.body, "do it");
assert_eq!(issue.labels, vec!["bug".to_string()]);
assert!(issue.open);
assert!(issue.assignees.is_empty());
assert_eq!(issue.branch_name(), "fix/1232-add-the-thing");
}
#[test]
fn gh_validate_parses_assignees() {
let json = r#"{"number":7,"title":"t","body":"","state":"OPEN","labels":[],"assignees":[{"login":"alice"},{"login":"bob"}]}"#;
let sys = GhTicketSystem::new(FakeRunner::new(vec![ok_out(json)]));
let issue = sys.validate(7).expect("should validate");
assert_eq!(
issue.assignees,
vec!["alice".to_string(), "bob".to_string()]
);
}
#[test]
fn gh_validate_rejects_closed() {
let json = r#"{"number":5,"title":"old","body":"","state":"CLOSED","labels":[]}"#;
let sys = GhTicketSystem::new(FakeRunner::new(vec![ok_out(json)]));
let err = sys.validate(5).unwrap_err().to_string();
assert!(
err.contains("closed"),
"expected closed message, got: {err}"
);
}
#[test]
fn gh_validate_rejects_missing() {
let sys = GhTicketSystem::new(FakeRunner::new(vec![fail_out(
"GraphQL: Could not resolve to an Issue",
)]));
let err = sys.validate(99999).unwrap_err().to_string();
assert!(
err.contains("could not be resolved"),
"expected resolve error, got: {err}"
);
}
#[test]
fn gh_comment_posts() {
let sys = GhTicketSystem::new(FakeRunner::new(vec![ok_out("")]));
sys.comment(1232, "starting work").expect("should comment");
}
#[test]
fn command_output_ok_returns_stdout() {
let out = ok_out(" hello ");
assert_eq!(out.ok_or_stderr("gh").unwrap(), "hello");
}
#[test]
fn command_output_err_includes_stderr() {
let out = fail_out("boom");
let err = out.ok_or_stderr("gh").unwrap_err().to_string();
assert!(err.contains("boom"), "got: {err}");
}
#[test]
fn stub_jira_not_supported() {
let err = not_yet_supported("jira").to_string();
assert!(err.contains("jira"));
assert!(err.contains("not yet supported"));
}
#[test]
fn stub_linear_not_supported() {
let err = not_yet_supported("linear").to_string();
assert!(err.contains("linear"));
assert!(err.contains("not yet supported"));
}
}