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