use std::fmt;
use anyhow::Result;
use crate::github::{self, GitHub};
use crate::issue::IssueUrl;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Label {
name: &'static str,
description: &'static str,
}
impl Label {
pub const fn new(name: &'static str, description: &'static str) -> Label {
Label { name, description }
}
pub fn name(self) -> &'static str {
self.name
}
pub fn description(self) -> &'static str {
self.description
}
pub fn is_named(self, name: &str) -> bool {
name.eq_ignore_ascii_case(self.name)
}
}
impl fmt::Display for Label {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name)
}
}
pub const NEEDS_TRIAGE: Label =
Label::new("needs-triage", "Maintainer needs to evaluate this issue");
pub const READY_FOR_AGENT: Label = Label::new("ready-for-agent", "Ready for an agent to take on");
pub const UNREADY: [Label; 4] = [
Label::new("ready-for-human", "Requires human implementation"),
Label::new("needs-info", "Waiting on reporter for more information"),
Label::new("wontfix", "Will not be actioned"),
NEEDS_TRIAGE,
];
#[derive(Debug, Clone, Default)]
pub struct Labels(Vec<String>);
impl<S: Into<String>> FromIterator<S> for Labels {
fn from_iter<I: IntoIterator<Item = S>>(names: I) -> Labels {
Labels(names.into_iter().map(Into::into).collect())
}
}
impl Labels {
pub fn has(&self, label: Label) -> bool {
self.spelled(label).is_some()
}
pub fn spelled(&self, label: Label) -> Option<&str> {
self.0
.iter()
.map(String::as_str)
.find(|name| label.is_named(name))
}
pub fn unready(&self) -> Option<Label> {
UNREADY.into_iter().find(|label| self.has(*label))
}
pub fn swapped(&self, off: &[Label], on: &[Label]) -> Labels {
let kept = self
.0
.iter()
.filter(|name| !off.iter().chain(on).any(|label| label.is_named(name)))
.cloned();
Labels(
kept.chain(on.iter().map(|label| label.name.to_string()))
.collect(),
)
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.0.iter().map(String::as_str)
}
}
pub struct Edit<'a> {
issue: &'a IssueUrl,
labels: Labels,
off: Vec<Label>,
on: Vec<Label>,
}
#[derive(Debug)]
enum Request {
None,
Delete(String),
Put { missing: Vec<Label>, labels: Labels },
}
impl<'a> Edit<'a> {
pub fn of(issue: &'a IssueUrl, labels: Labels, off: &[Label], on: &[Label]) -> Edit<'a> {
Edit {
issue,
labels,
off: off.to_vec(),
on: on.to_vec(),
}
}
pub fn takes_off(&self, label: Label) -> bool {
self.off.contains(&label) && !self.on.contains(&label) && self.labels.has(label)
}
pub fn puts_on(&self, label: Label) -> bool {
self.on.contains(&label) && !self.labels.has(label)
}
pub fn labels(&self) -> &Labels {
&self.labels
}
#[cfg(test)]
pub fn issue(&self) -> &IssueUrl {
self.issue
}
#[cfg(test)]
pub fn changes(&self) -> (Vec<Label>, Vec<Label>) {
let off = self
.off
.iter()
.copied()
.filter(|label| self.takes_off(*label));
let on = self.on.iter().copied().filter(|label| self.puts_on(*label));
(off.collect(), on.collect())
}
#[cfg(test)]
pub fn labels_after(&self) -> Labels {
self.labels.swapped(&self.off, &self.on)
}
pub fn apply(&self, github: &GitHub) -> Result<()> {
match request(&self.labels, &self.off, &self.on) {
Request::None => Ok(()),
Request::Delete(label) => github.remove_label(self.issue, &label),
Request::Put { missing, labels } => {
if !missing.is_empty() {
github.ensure_labels(&self.issue.repo_slug(), &missing)?;
}
github.set_labels(self.issue, &labels)
}
}
}
}
fn request(labels: &Labels, off: &[Label], on: &[Label]) -> Request {
let taken_off: Vec<&str> = off
.iter()
.filter(|label| !on.contains(label))
.filter_map(|label| labels.spelled(*label))
.collect();
let missing: Vec<Label> = on
.iter()
.copied()
.filter(|label| !labels.has(*label))
.collect();
match (taken_off.as_slice(), missing.is_empty()) {
([], true) => Request::None,
([label], true) => Request::Delete(label.to_string()),
_ => Request::Put {
labels: labels.swapped(off, on),
missing,
},
}
}
pub fn by_hand(issue: &IssueUrl, off: &[Label], on: &[Label]) -> String {
let removes = off
.iter()
.map(|label| github::remove_label_command(issue, *label));
let adds = on
.iter()
.map(|label| github::add_label_command(issue, *label));
removes.chain(adds).collect::<Vec<_>>().join(" && ")
}
#[cfg(test)]
mod tests {
use super::*;
const IN_PROGRESS: Label = Label::new("in-progress", "A Claim");
fn labels(names: &[&str]) -> Labels {
names.iter().copied().collect()
}
fn names(labels: &Labels) -> Vec<&str> {
labels.names().collect()
}
const ARCHITECT_PLAN: Label = Label::new("architect-plan", "An Architect plan");
fn put(missing: &[Label], labels: &[&str]) -> Request {
Request::Put {
missing: missing.to_vec(),
labels: self::labels(labels),
}
}
fn compared(request: Request) -> (Option<String>, Vec<Label>, Vec<String>) {
match request {
Request::None => (None, Vec::new(), Vec::new()),
Request::Delete(label) => (Some(label), Vec::new(), Vec::new()),
Request::Put { missing, labels } => {
(None, missing, labels.names().map(str::to_string).collect())
}
}
}
fn assert_request(actual: Request, expected: Request) {
assert_eq!(compared(actual), compared(expected));
}
#[test]
fn an_edit_that_changes_no_label_whatever_its_case_makes_no_request() {
let labels = labels(&["bug", "In-Progress"]);
assert_request(request(&labels, &[], &[]), Request::None);
assert_request(request(&labels, &[READY_FOR_AGENT], &[]), Request::None);
assert_request(
request(&labels, &[READY_FOR_AGENT], &[IN_PROGRESS]),
Request::None,
);
}
#[test]
fn taking_one_label_off_and_putting_none_on_deletes_it_as_the_issue_spells_it() {
let triaged = labels(&["NEEDS-TRIAGE", "In-Progress"]);
let labels = labels(&["bug", "In-Progress"]);
assert_request(
request(&labels, &[IN_PROGRESS], &[]),
Request::Delete("In-Progress".into()),
);
assert_request(
request(&triaged, &[IN_PROGRESS], &[NEEDS_TRIAGE]),
Request::Delete("In-Progress".into()),
);
}
#[test]
fn a_label_both_taken_off_and_put_on_is_put_on() {
let labels = labels(&["In-Progress", "bug"]);
assert_request(
request(&labels, &[IN_PROGRESS], &[IN_PROGRESS]),
Request::None,
);
assert_request(
request(&labels, &[IN_PROGRESS, NEEDS_TRIAGE], &[IN_PROGRESS]),
Request::None,
);
}
#[test]
fn taking_two_labels_off_writes_the_rest() {
let labels = labels(&["Needs-Triage", "bug", "in-progress"]);
assert_request(
request(&labels, &[NEEDS_TRIAGE, IN_PROGRESS], &[]),
put(&[], &["bug"]),
);
}
#[test]
fn putting_labels_on_adds_those_the_issue_lacks_to_the_repository_first() {
let labels = labels(&["Needs-Triage", "bug", "ARCHITECT-PLAN"]);
assert_request(
request(&labels, &[NEEDS_TRIAGE], &[READY_FOR_AGENT, ARCHITECT_PLAN]),
put(
&[READY_FOR_AGENT],
&["bug", "ready-for-agent", "architect-plan"],
),
);
}
#[test]
fn a_claim_swaps_ready_for_agent_for_in_progress_writing_all_the_labels() {
let labels = labels(&["Ready-For-Agent", "bug"]);
assert_request(
request(&labels, &[READY_FOR_AGENT], &[IN_PROGRESS]),
put(&[IN_PROGRESS], &["bug", "in-progress"]),
);
}
#[test]
fn an_edit_says_what_it_takes_off_and_puts_on_whatever_the_case() {
let issue = IssueUrl::parse("https://github.com/acme/widgets/issues/7").unwrap();
let edit = Edit::of(
&issue,
labels(&["READY-FOR-AGENT", "In-Progress"]),
&[READY_FOR_AGENT, NEEDS_TRIAGE],
&[IN_PROGRESS, ARCHITECT_PLAN],
);
assert!(edit.takes_off(READY_FOR_AGENT));
assert!(!edit.takes_off(NEEDS_TRIAGE));
assert!(!edit.takes_off(IN_PROGRESS));
assert!(!edit.puts_on(IN_PROGRESS));
assert!(edit.puts_on(ARCHITECT_PLAN));
assert!(!edit.puts_on(READY_FOR_AGENT));
assert_eq!(names(edit.labels()), ["READY-FOR-AGENT", "In-Progress"]);
}
#[test]
fn the_commands_by_hand_take_off_then_put_on() {
let issue = IssueUrl::parse("https://github.com/acme/widgets/issues/7").unwrap();
assert_eq!(by_hand(&issue, &[], &[]), "");
assert_eq!(
by_hand(&issue, &[IN_PROGRESS], &[READY_FOR_AGENT]),
"gh api --method DELETE repos/acme/widgets/issues/7/labels/in-progress \
&& gh api --method POST repos/acme/widgets/issues/7/labels -f 'labels[]=ready-for-agent'"
);
}
#[test]
fn a_label_is_had_and_spelled_whatever_its_case() {
let labels = labels(&["bug", "Ready-For-Agent"]);
assert!(labels.has(READY_FOR_AGENT));
assert_eq!(labels.spelled(READY_FOR_AGENT), Some("Ready-For-Agent"));
assert!(!labels.has(NEEDS_TRIAGE));
assert_eq!(labels.spelled(NEEDS_TRIAGE), None);
}
#[test]
fn a_label_prints_as_its_name() {
assert_eq!(NEEDS_TRIAGE.to_string(), "needs-triage");
assert_eq!(format!("{READY_FOR_AGENT}"), "ready-for-agent");
}
#[test]
fn the_first_unready_label_is_found_in_any_case() {
assert_eq!(labels(&["bug"]).unready(), None);
for label in UNREADY {
let upper = label.name().to_uppercase();
assert_eq!(labels(&["bug", &upper]).unready(), Some(label), "{label}");
}
assert_eq!(
labels(&["Needs-Triage", "WONTFIX"])
.unready()
.map(Label::name),
Some("wontfix")
);
}
#[test]
fn taking_off_a_label_takes_it_off_whatever_its_case() {
let labels = labels(&["Needs-Triage", "bug"]);
assert_eq!(names(&labels.swapped(&[NEEDS_TRIAGE], &[])), ["bug"]);
}
#[test]
fn a_claim_swaps_ready_for_agent_for_in_progress_in_any_case() {
let labels = labels(&["Ready-For-Agent", "bug", "IN-PROGRESS"]);
assert_eq!(
names(&labels.swapped(&[READY_FOR_AGENT], &[IN_PROGRESS])),
["bug", "in-progress"]
);
}
#[test]
fn releasing_a_claim_swaps_in_progress_for_ready_for_agent_in_any_case() {
let labels = labels(&["bug", "In-Progress", "later"]);
assert_eq!(
names(&labels.swapped(&[IN_PROGRESS], &[READY_FOR_AGENT])),
["bug", "later", "ready-for-agent"]
);
}
#[test]
fn a_label_put_on_that_is_already_there_is_there_once_as_put_on() {
let labels = labels(&["READY-FOR-AGENT", "bug"]);
assert_eq!(
names(&labels.swapped(&[], &[READY_FOR_AGENT])),
["bug", "ready-for-agent"]
);
}
#[test]
fn swapping_leaves_the_labels_it_was_asked_of_as_they_were() {
let before = labels(&["Needs-Triage", "bug"]);
let _ = before.swapped(&[NEEDS_TRIAGE], &[READY_FOR_AGENT]);
assert_eq!(names(&before), ["Needs-Triage", "bug"]);
}
}