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