use serde::Deserialize;
use super::runner::CommandRunner;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct RepoLabel {
pub(crate) name: String,
#[serde(default)]
pub(crate) color: String,
#[serde(default)]
pub(crate) description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AssigneeTarget {
SelfUser,
Login(String),
None,
}
pub(crate) fn gh_list_repo_labels<R: CommandRunner>(runner: &R) -> anyhow::Result<Vec<RepoLabel>> {
let out = runner.run("gh", &["label", "list", "--json", "name,color,description"])?;
let json = out.ok_or_stderr("gh label list")?;
if json.is_empty() {
return Ok(Vec::new());
}
let labels: Vec<RepoLabel> = serde_json::from_str(&json)
.map_err(|e| anyhow::anyhow!("failed to parse `gh label list` JSON: {e}"))?;
Ok(labels)
}
pub(crate) fn gh_create_label<R: CommandRunner>(
runner: &R,
label: &RepoLabel,
) -> anyhow::Result<()> {
let mut args: Vec<&str> = vec!["label", "create", &label.name];
if !label.color.is_empty() {
args.push("--color");
args.push(&label.color);
}
if !label.description.is_empty() {
args.push("--description");
args.push(&label.description);
}
runner.run("gh", &args)?.ok_or_stderr("gh label create")?;
Ok(())
}
pub(crate) fn gh_add_label<R: CommandRunner>(
runner: &R,
issue: u64,
label: &str,
) -> anyhow::Result<()> {
let n = issue.to_string();
runner
.run("gh", &["issue", "edit", &n, "--add-label", label])?
.ok_or_stderr("gh issue edit")?;
Ok(())
}
pub(crate) fn gh_remove_label<R: CommandRunner>(
runner: &R,
issue: u64,
label: &str,
) -> anyhow::Result<()> {
let n = issue.to_string();
runner
.run("gh", &["issue", "edit", &n, "--remove-label", label])?
.ok_or_stderr("gh issue edit")?;
Ok(())
}
pub(crate) fn gh_swap_labels<R: CommandRunner>(
runner: &R,
issue: u64,
add: &str,
remove: &str,
) -> anyhow::Result<()> {
let n = issue.to_string();
runner
.run(
"gh",
&[
"issue",
"edit",
&n,
"--add-label",
add,
"--remove-label",
remove,
],
)?
.ok_or_stderr("gh issue edit")?;
Ok(())
}
pub(crate) fn gh_set_assignee<R: CommandRunner>(
runner: &R,
issue: u64,
who: &AssigneeTarget,
current_assignees: &[String],
) -> anyhow::Result<()> {
let n = issue.to_string();
match who {
AssigneeTarget::SelfUser => {
let login = runner
.run("gh", &["api", "user", "--jq", ".login"])?
.ok_or_stderr("gh api user")?;
if login.is_empty() {
anyhow::bail!("could not resolve the authenticated `gh` user for self-assignment");
}
runner
.run("gh", &["issue", "edit", &n, "--add-assignee", &login])?
.ok_or_stderr("gh issue edit")?;
}
AssigneeTarget::Login(login) => {
runner
.run("gh", &["issue", "edit", &n, "--add-assignee", login])?
.ok_or_stderr("gh issue edit")?;
}
AssigneeTarget::None => {
for login in current_assignees {
runner
.run("gh", &["issue", "edit", &n, "--remove-assignee", login])?
.ok_or_stderr("gh issue edit")?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::super::runner::{CommandOutput, CommandRunner};
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()),
}
}
fn calls(&self) -> Vec<(String, Vec<String>)> {
self.calls.borrow().clone()
}
}
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 gh_list_repo_labels_parses() {
let json = r#"[{"name":"unicorn:queued","color":"BFD4F2","description":"Queued"}]"#;
let r = FakeRunner::new(vec![ok_out(json)]);
let labels = gh_list_repo_labels(&r).expect("parse");
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].name, "unicorn:queued");
assert_eq!(labels[0].color, "BFD4F2");
assert_eq!(labels[0].description, "Queued");
assert_eq!(
r.calls()[0].1,
vec!["label", "list", "--json", "name,color,description"]
);
}
#[test]
fn gh_list_repo_labels_empty_is_empty() {
let r = FakeRunner::new(vec![ok_out("")]);
assert!(gh_list_repo_labels(&r).expect("empty ok").is_empty());
}
#[test]
fn gh_list_repo_labels_errors() {
let r = FakeRunner::new(vec![fail_out("not a repo")]);
assert!(gh_list_repo_labels(&r).is_err());
}
#[test]
fn gh_create_label_invokes_create() {
let r = FakeRunner::new(vec![ok_out("")]);
let label = RepoLabel {
name: "unicorn:done".to_string(),
color: "0075CA".to_string(),
description: "Done".to_string(),
};
gh_create_label(&r, &label).expect("create");
assert_eq!(
r.calls()[0].1,
vec![
"label",
"create",
"unicorn:done",
"--color",
"0075CA",
"--description",
"Done"
]
);
}
#[test]
fn gh_create_label_omits_empty_description() {
let r = FakeRunner::new(vec![ok_out("")]);
let label = RepoLabel {
name: "T4".to_string(),
color: "0075CA".to_string(),
description: String::new(),
};
gh_create_label(&r, &label).expect("create");
let args = &r.calls()[0].1;
assert!(!args.iter().any(|a| a == "--description"));
assert!(args.iter().any(|a| a == "--color"));
}
#[test]
fn gh_add_label_invokes_edit() {
let r = FakeRunner::new(vec![ok_out("")]);
gh_add_label(&r, 42, "unicorn:approved").expect("add");
assert_eq!(
r.calls()[0].1,
vec!["issue", "edit", "42", "--add-label", "unicorn:approved"]
);
}
#[test]
fn gh_remove_label_invokes_edit() {
let r = FakeRunner::new(vec![ok_out("")]);
gh_remove_label(&r, 42, "unicorn:queued").expect("remove");
assert_eq!(
r.calls()[0].1,
vec!["issue", "edit", "42", "--remove-label", "unicorn:queued"]
);
}
#[test]
fn gh_swap_labels_single_call() {
let r = FakeRunner::new(vec![ok_out("")]);
gh_swap_labels(&r, 7, "unicorn:approved", "unicorn:queued").expect("swap");
let calls = r.calls();
assert_eq!(calls.len(), 1, "swap must be a single gh call");
assert_eq!(
calls[0].1,
vec![
"issue",
"edit",
"7",
"--add-label",
"unicorn:approved",
"--remove-label",
"unicorn:queued"
]
);
}
#[test]
fn gh_set_assignee_self() {
let r = FakeRunner::new(vec![ok_out("octocat"), ok_out("")]);
gh_set_assignee(&r, 7, &AssigneeTarget::SelfUser, &[]).expect("self");
let calls = r.calls();
assert_eq!(calls[0].1, vec!["api", "user", "--jq", ".login"]);
assert_eq!(
calls[1].1,
vec!["issue", "edit", "7", "--add-assignee", "octocat"]
);
}
#[test]
fn gh_set_assignee_login() {
let r = FakeRunner::new(vec![ok_out("")]);
gh_set_assignee(&r, 7, &AssigneeTarget::Login("alice".to_string()), &[]).expect("login");
assert_eq!(
r.calls()[0].1,
vec!["issue", "edit", "7", "--add-assignee", "alice"]
);
}
#[test]
fn gh_set_assignee_none_removes_each() {
let r = FakeRunner::new(vec![ok_out(""), ok_out("")]);
let current = vec!["alice".to_string(), "bob".to_string()];
gh_set_assignee(&r, 7, &AssigneeTarget::None, ¤t).expect("none");
let calls = r.calls();
assert_eq!(calls.len(), 2);
assert_eq!(
calls[0].1,
vec!["issue", "edit", "7", "--remove-assignee", "alice"]
);
assert_eq!(
calls[1].1,
vec!["issue", "edit", "7", "--remove-assignee", "bob"]
);
}
#[test]
fn gh_set_assignee_none_empty_is_noop() {
let r = FakeRunner::new(vec![]);
gh_set_assignee(&r, 7, &AssigneeTarget::None, &[]).expect("noop");
assert!(
r.calls().is_empty(),
"empty assignee set must make zero gh calls"
);
}
}