pub mod gitea;
pub mod linear;
pub mod pagerank;
pub use gitea::{CommentUser, GiteaComment, GiteaConfig, GiteaTracker, IssueComment};
pub use linear::{LinearConfig, LinearTracker};
pub use pagerank::{PagerankClient, PagerankScore};
use async_trait::async_trait;
use jiff::Zoned;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub id: String,
pub identifier: String,
pub title: String,
pub description: Option<String>,
pub priority: Option<i32>,
pub state: String,
pub branch_name: Option<String>,
pub url: Option<String>,
pub labels: Vec<String>,
pub blocked_by: Vec<BlockerRef>,
pub pagerank_score: Option<f64>,
pub created_at: Option<Zoned>,
pub updated_at: Option<Zoned>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockerRef {
pub id: Option<String>,
pub identifier: Option<String>,
pub state: Option<String>,
}
#[async_trait]
pub trait IssueTracker: Send + Sync {
async fn fetch_candidate_issues(&self) -> Result<Vec<Issue>>;
async fn fetch_issue_states_by_ids(&self, ids: &[String]) -> Result<Vec<Issue>>;
async fn fetch_issues_by_states(&self, states: &[String]) -> Result<Vec<Issue>>;
}
#[derive(thiserror::Error, Debug)]
pub enum TrackerError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("API error: {message}")]
Api { message: String },
#[error("GraphQL error: {message}")]
GraphQLError { message: String },
#[error("Authentication missing for {service}")]
AuthenticationMissing { service: String },
#[error("Validation failed: {checks:?}")]
ValidationFailed { checks: Vec<String> },
}
pub type Result<T> = std::result::Result<T, TrackerError>;
impl Issue {
pub fn is_dispatchable(&self) -> bool {
!self.id.is_empty()
&& !self.identifier.is_empty()
&& !self.title.is_empty()
&& !self.state.is_empty()
}
pub fn all_blockers_terminal(&self, terminal_states: &[String]) -> bool {
self.blocked_by.iter().all(|b| {
b.state
.as_ref()
.is_some_and(|s| terminal_states.iter().any(|t| t.eq_ignore_ascii_case(s)))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_issue() -> Issue {
Issue {
id: "abc123".into(),
identifier: "MT-42".into(),
title: "Fix the widget".into(),
description: Some("It is broken.".into()),
priority: Some(1),
state: "Todo".into(),
branch_name: None,
url: Some("https://example.com/MT-42".into()),
labels: vec!["bug".into(), "p1".into()],
blocked_by: vec![],
pagerank_score: None,
created_at: Some(Zoned::now()),
updated_at: Some(Zoned::now()),
}
}
#[test]
fn dispatchable_with_required_fields() {
let issue = sample_issue();
assert!(issue.is_dispatchable());
}
#[test]
fn not_dispatchable_without_id() {
let mut issue = sample_issue();
issue.id = String::new();
assert!(!issue.is_dispatchable());
}
#[test]
fn not_dispatchable_without_state() {
let mut issue = sample_issue();
issue.state = String::new();
assert!(!issue.is_dispatchable());
}
#[test]
fn no_blockers_means_all_terminal() {
let issue = sample_issue();
assert!(issue.all_blockers_terminal(&["Done".into(), "Closed".into()]));
}
#[test]
fn terminal_blockers_pass() {
let mut issue = sample_issue();
issue.blocked_by = vec![BlockerRef {
id: Some("def456".into()),
identifier: Some("MT-10".into()),
state: Some("Done".into()),
}];
assert!(issue.all_blockers_terminal(&["Done".into(), "Closed".into()]));
}
#[test]
fn non_terminal_blockers_fail() {
let mut issue = sample_issue();
issue.blocked_by = vec![BlockerRef {
id: Some("def456".into()),
identifier: Some("MT-10".into()),
state: Some("In Progress".into()),
}];
assert!(!issue.all_blockers_terminal(&["Done".into(), "Closed".into()]));
}
#[test]
fn blocker_state_comparison_is_case_insensitive() {
let mut issue = sample_issue();
issue.blocked_by = vec![BlockerRef {
id: None,
identifier: None,
state: Some("done".into()),
}];
assert!(issue.all_blockers_terminal(&["Done".into()]));
}
}