1pub mod gitea;
7pub mod linear;
8pub mod pagerank;
9
10pub use gitea::{CommentUser, GiteaComment, GiteaConfig, GiteaTracker, IssueComment};
11pub use linear::{LinearConfig, LinearTracker};
12pub use pagerank::{PagerankClient, PagerankScore};
13
14use async_trait::async_trait;
15use jiff::Zoned;
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Issue {
21 pub id: String,
23 pub identifier: String,
25 pub title: String,
27 pub description: Option<String>,
29 pub priority: Option<i32>,
31 pub state: String,
33 pub branch_name: Option<String>,
35 pub url: Option<String>,
37 pub labels: Vec<String>,
39 pub blocked_by: Vec<BlockerRef>,
41 pub pagerank_score: Option<f64>,
43 pub created_at: Option<Zoned>,
45 pub updated_at: Option<Zoned>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct BlockerRef {
52 pub id: Option<String>,
54 pub identifier: Option<String>,
56 pub state: Option<String>,
58}
59
60#[async_trait]
62pub trait IssueTracker: Send + Sync {
63 async fn fetch_candidate_issues(&self) -> Result<Vec<Issue>>;
65
66 async fn fetch_issue_states_by_ids(&self, ids: &[String]) -> Result<Vec<Issue>>;
68
69 async fn fetch_issues_by_states(&self, states: &[String]) -> Result<Vec<Issue>>;
71}
72
73#[derive(thiserror::Error, Debug)]
75pub enum TrackerError {
76 #[error("HTTP request failed: {0}")]
77 Http(#[from] reqwest::Error),
78
79 #[error("API error: {message}")]
80 Api { message: String },
81
82 #[error("GraphQL error: {message}")]
83 GraphQLError { message: String },
84
85 #[error("Authentication missing for {service}")]
86 AuthenticationMissing { service: String },
87
88 #[error("Validation failed: {checks:?}")]
89 ValidationFailed { checks: Vec<String> },
90}
91
92pub type Result<T> = std::result::Result<T, TrackerError>;
94
95impl Issue {
96 pub fn is_dispatchable(&self) -> bool {
98 !self.id.is_empty()
99 && !self.identifier.is_empty()
100 && !self.title.is_empty()
101 && !self.state.is_empty()
102 }
103
104 pub fn all_blockers_terminal(&self, terminal_states: &[String]) -> bool {
106 self.blocked_by.iter().all(|b| {
107 b.state
108 .as_ref()
109 .is_some_and(|s| terminal_states.iter().any(|t| t.eq_ignore_ascii_case(s)))
110 })
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 fn sample_issue() -> Issue {
119 Issue {
120 id: "abc123".into(),
121 identifier: "MT-42".into(),
122 title: "Fix the widget".into(),
123 description: Some("It is broken.".into()),
124 priority: Some(1),
125 state: "Todo".into(),
126 branch_name: None,
127 url: Some("https://example.com/MT-42".into()),
128 labels: vec!["bug".into(), "p1".into()],
129 blocked_by: vec![],
130 pagerank_score: None,
131 created_at: Some(Zoned::now()),
132 updated_at: Some(Zoned::now()),
133 }
134 }
135
136 #[test]
137 fn dispatchable_with_required_fields() {
138 let issue = sample_issue();
139 assert!(issue.is_dispatchable());
140 }
141
142 #[test]
143 fn not_dispatchable_without_id() {
144 let mut issue = sample_issue();
145 issue.id = String::new();
146 assert!(!issue.is_dispatchable());
147 }
148
149 #[test]
150 fn not_dispatchable_without_state() {
151 let mut issue = sample_issue();
152 issue.state = String::new();
153 assert!(!issue.is_dispatchable());
154 }
155
156 #[test]
157 fn no_blockers_means_all_terminal() {
158 let issue = sample_issue();
159 assert!(issue.all_blockers_terminal(&["Done".into(), "Closed".into()]));
160 }
161
162 #[test]
163 fn terminal_blockers_pass() {
164 let mut issue = sample_issue();
165 issue.blocked_by = vec![BlockerRef {
166 id: Some("def456".into()),
167 identifier: Some("MT-10".into()),
168 state: Some("Done".into()),
169 }];
170 assert!(issue.all_blockers_terminal(&["Done".into(), "Closed".into()]));
171 }
172
173 #[test]
174 fn non_terminal_blockers_fail() {
175 let mut issue = sample_issue();
176 issue.blocked_by = vec![BlockerRef {
177 id: Some("def456".into()),
178 identifier: Some("MT-10".into()),
179 state: Some("In Progress".into()),
180 }];
181 assert!(!issue.all_blockers_terminal(&["Done".into(), "Closed".into()]));
182 }
183
184 #[test]
185 fn blocker_state_comparison_is_case_insensitive() {
186 let mut issue = sample_issue();
187 issue.blocked_by = vec![BlockerRef {
188 id: None,
189 identifier: None,
190 state: Some("done".into()),
191 }];
192 assert!(issue.all_blockers_terminal(&["Done".into()]));
193 }
194}