Skip to main content

terraphim_tracker/
lib.rs

1//! Issue tracker abstraction and normalised issue model.
2//!
3//! Provides the [`IssueTracker`] trait and the [`Issue`] model that all tracker
4//! implementations normalise to.
5
6pub 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/// A normalised issue from any tracker.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Issue {
21    /// Stable tracker-internal ID.
22    pub id: String,
23    /// Human-readable ticket key (e.g. `ABC-123`, `owner/repo#42`).
24    pub identifier: String,
25    /// Issue title.
26    pub title: String,
27    /// Issue body/description, if any.
28    pub description: Option<String>,
29    /// Priority (lower = higher priority). `None` means unset.
30    pub priority: Option<i32>,
31    /// Current tracker state name (e.g. "Todo", "In Progress").
32    pub state: String,
33    /// Tracker-provided branch name metadata, if any.
34    pub branch_name: Option<String>,
35    /// URL to the issue in the tracker UI.
36    pub url: Option<String>,
37    /// Labels, normalised to lowercase.
38    pub labels: Vec<String>,
39    /// Issues that block this one.
40    pub blocked_by: Vec<BlockerRef>,
41    /// PageRank score from dependency graph analysis. Higher = more downstream impact.
42    pub pagerank_score: Option<f64>,
43    /// Creation timestamp.
44    pub created_at: Option<Zoned>,
45    /// Last update timestamp.
46    pub updated_at: Option<Zoned>,
47}
48
49/// A reference to a blocking issue.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct BlockerRef {
52    /// Tracker-internal ID, if known.
53    pub id: Option<String>,
54    /// Human-readable identifier, if known.
55    pub identifier: Option<String>,
56    /// Current state of the blocker, if known.
57    pub state: Option<String>,
58}
59
60/// The issue tracker contract.
61#[async_trait]
62pub trait IssueTracker: Send + Sync {
63    /// Fetch issues in configured active states for the configured project.
64    async fn fetch_candidate_issues(&self) -> Result<Vec<Issue>>;
65
66    /// Fetch current states for specific issue IDs.
67    async fn fetch_issue_states_by_ids(&self, ids: &[String]) -> Result<Vec<Issue>>;
68
69    /// Fetch issues currently in the given states.
70    async fn fetch_issues_by_states(&self, states: &[String]) -> Result<Vec<Issue>>;
71}
72
73/// Errors that can occur during tracker operations.
74#[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
92/// Result type for tracker operations.
93pub type Result<T> = std::result::Result<T, TrackerError>;
94
95impl Issue {
96    /// Check whether this issue has the minimum required fields for dispatch.
97    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    /// Check whether all blockers are in terminal states.
105    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}