use crate::error::Result;
use crate::git::{
issue_close, issue_create, issue_edit, issue_find, IssueCloseOptions, IssueCloseReason,
IssueCreateOptions, IssueEditOptions, IssueFindOptions, IssueState,
};
use super::plan::{TrackedIssue, TrackedIssueState};
pub trait Tracker {
fn list_issues(&self, command_label: &str, limit: usize) -> Result<Vec<TrackedIssue>>;
fn create_issue(&self, title: &str, body: &str, labels: &[String]) -> Result<u64>;
fn update_issue(&self, number: u64, title: Option<&str>, body: Option<&str>) -> Result<()>;
fn close_issue(&self, number: u64, reason: CloseReason, comment: Option<&str>) -> Result<()>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloseReason {
Completed,
NotPlanned,
}
impl CloseReason {
fn to_github(self) -> IssueCloseReason {
match self {
CloseReason::Completed => IssueCloseReason::Completed,
CloseReason::NotPlanned => IssueCloseReason::NotPlanned,
}
}
}
pub struct GithubTracker {
component_id: String,
path: Option<String>,
}
impl GithubTracker {
pub fn new(component_id: impl Into<String>) -> Self {
Self {
component_id: component_id.into(),
path: None,
}
}
pub fn with_path(mut self, path: Option<String>) -> Self {
self.path = path;
self
}
}
impl Tracker for GithubTracker {
fn list_issues(&self, command_label: &str, limit: usize) -> Result<Vec<TrackedIssue>> {
let out = issue_find(
Some(&self.component_id),
IssueFindOptions {
title: None,
labels: vec![command_label.to_string()],
state: IssueState::All,
limit,
path: self.path.clone(),
},
)?;
let issues = out
.items
.into_iter()
.filter_map(github_to_tracked)
.collect();
Ok(issues)
}
fn create_issue(&self, title: &str, body: &str, labels: &[String]) -> Result<u64> {
let out = issue_create(
Some(&self.component_id),
IssueCreateOptions {
title: title.to_string(),
body: body.to_string(),
labels: labels.to_vec(),
path: self.path.clone(),
},
)?;
out.number.ok_or_else(|| {
crate::error::Error::internal_io(
"issue.create succeeded but returned no number".to_string(),
Some("gh issue create".into()),
)
})
}
fn update_issue(&self, number: u64, title: Option<&str>, body: Option<&str>) -> Result<()> {
issue_edit(
Some(&self.component_id),
IssueEditOptions {
number,
title: title.map(|s| s.to_string()),
body: body.map(|s| s.to_string()),
add_labels: Vec::new(),
remove_labels: Vec::new(),
path: self.path.clone(),
},
)?;
Ok(())
}
fn close_issue(&self, number: u64, reason: CloseReason, comment: Option<&str>) -> Result<()> {
issue_close(
Some(&self.component_id),
IssueCloseOptions {
number,
reason: reason.to_github(),
comment: comment.map(|s| s.to_string()),
path: self.path.clone(),
},
)?;
Ok(())
}
}
fn github_to_tracked(item: crate::git::GithubFindItem) -> Option<TrackedIssue> {
let state = match item.state.to_lowercase().as_str() {
"open" => TrackedIssueState::Open,
"closed" => match item.state_reason.as_str() {
"not_planned" | "NOT_PLANNED" => TrackedIssueState::ClosedNotPlanned,
"" | "completed" | "COMPLETED" => TrackedIssueState::ClosedCompleted,
_ => TrackedIssueState::ClosedCompleted,
},
_ => return None,
};
Some(TrackedIssue {
number: item.number,
title: item.title,
body: item.body,
url: item.url,
state,
labels: item.labels,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::GithubFindItem;
fn item(state: &str, state_reason: &str) -> GithubFindItem {
GithubFindItem {
number: 1,
title: "t".into(),
body: String::new(),
url: "u".into(),
state: state.into(),
state_reason: state_reason.into(),
closed_at: String::new(),
labels: vec!["audit".into()],
}
}
#[test]
fn translates_open() {
assert_eq!(
github_to_tracked(item("OPEN", "")).unwrap().state,
TrackedIssueState::Open
);
assert_eq!(
github_to_tracked(item("open", "")).unwrap().state,
TrackedIssueState::Open
);
}
#[test]
fn translates_closed_completed() {
assert_eq!(
github_to_tracked(item("CLOSED", "completed"))
.unwrap()
.state,
TrackedIssueState::ClosedCompleted
);
}
#[test]
fn translates_closed_not_planned() {
assert_eq!(
github_to_tracked(item("closed", "not_planned"))
.unwrap()
.state,
TrackedIssueState::ClosedNotPlanned
);
assert_eq!(
github_to_tracked(item("CLOSED", "NOT_PLANNED"))
.unwrap()
.state,
TrackedIssueState::ClosedNotPlanned
);
}
#[test]
fn empty_state_reason_on_closed_defaults_to_completed() {
assert_eq!(
github_to_tracked(item("CLOSED", "")).unwrap().state,
TrackedIssueState::ClosedCompleted
);
}
#[test]
fn unknown_state_reason_falls_back_to_completed() {
assert_eq!(
github_to_tracked(item("CLOSED", "duplicate"))
.unwrap()
.state,
TrackedIssueState::ClosedCompleted
);
}
#[test]
fn unknown_state_returns_none() {
assert!(github_to_tracked(item("merged", "")).is_none());
assert!(github_to_tracked(item("draft", "")).is_none());
}
}