Skip to main content

gitlab_tracker_core/
provider.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Typed error returned by [`TrackerProvider`] operations.
6///
7/// Using `thiserror` instead of `Result<(), String>` gives callers a proper
8/// type to match on, enables `?` propagation, and keeps error messages
9/// consistent across all provider implementations.
10#[derive(Debug, thiserror::Error)]
11pub enum TrackerError {
12    /// A network or HTTP-level failure occurred.
13    #[error("Network error: {0}")]
14    Network(String),
15    /// The API token is missing, expired, or rejected.
16    #[error("Authentication failed: {0}")]
17    Auth(String),
18    /// The requested ticket does not exist on the tracker.
19    #[error("Ticket not found: {0}")]
20    NotFound(String),
21    /// The operation is not supported by this provider implementation.
22    #[error("Not supported by this tracker: {0}")]
23    Unsupported(String),
24    /// Any other provider-specific error not covered by the variants above.
25    #[error("{0}")]
26    Other(String),
27}
28
29/// A lightweight reference to an external tracker ticket linked to a MR.
30///
31/// This struct is the only data type exchanged between the orchestrator
32/// (`gitlab-tracker`) and any tracker plugin (Redmine, Jira, Trello, …).
33/// It is intentionally flat and display-oriented — the orchestrator does not
34/// need to know anything about the internal data model of the tracker.
35/// Current schema version for [`LinkedTicket`].
36///
37/// Increment this constant whenever fields are added to or removed from `LinkedTicket`.
38/// Any cached ticket whose `schema_version` is lower than this value will be invalidated
39/// and re-fetched from the tracker on the next startup, ensuring stale caches never
40/// silently hide newly added fields.
41pub const LINKED_TICKET_SCHEMA_VERSION: u32 = 2;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct LinkedTicket {
45    /// Schema version — used to detect stale cached tickets after a struct upgrade.
46    /// Defaults to `0` when absent (pre-versioning cache entries), which guarantees
47    /// they are always invalidated on first run after this field was introduced.
48    #[serde(default)]
49    pub schema_version: u32,
50    /// Numeric or alphanumeric identifier as shown in the tracker UI (e.g. "1234", "PROJ-42").
51    pub id: String,
52    /// Short title / subject of the ticket.
53    pub subject: String,
54    /// Human-readable status label (e.g. "In Progress", "Resolved").
55    pub status: String,
56    /// Direct URL to open the ticket in a browser.
57    pub url: String,
58    /// Original creator of the ticket (display name).
59    /// `None` when not provided by the tracker.
60    #[serde(default)]
61    pub author: Option<String>,
62    /// User currently assigned to the ticket (display name).
63    /// `None` when unassigned or not supported by the tracker.
64    #[serde(default)]
65    pub assignee: Option<String>,
66    /// Estimated time to complete the ticket, in seconds (e.g. from Redmine `/estimate`).
67    /// `None` when not set or not supported by the tracker.
68    #[serde(default)]
69    pub time_estimate: Option<u32>,
70    /// Time already spent on the ticket, in seconds (e.g. from Redmine time entries).
71    /// `None` when not set or not supported by the tracker.
72    #[serde(default)]
73    pub time_spent: Option<u32>,
74    /// Remaining time (ETC) in seconds as reported by the tracker.
75    /// `None` when not set or not supported by the tracker.
76    #[serde(default)]
77    pub time_remaining: Option<u32>,
78    /// Type / category of the ticket as defined by the tracker (e.g. "Bug", "Evolution").
79    /// The label is tracker-specific and may be in any language — do not hardcode colour logic
80    /// on its value; use `label_colors` in `redmine.yaml` instead.
81    /// `None` when not provided by the tracker.
82    #[serde(default)]
83    pub tracker_type: Option<String>,
84    /// Priority label of the ticket as defined by the tracker (e.g. "High", "Low").
85    /// Same caveat as `tracker_type` — colour mapping is user-configurable.
86    /// `None` when not provided by the tracker.
87    #[serde(default)]
88    pub priority: Option<String>,
89    /// Target version / sprint / release the ticket is assigned to (e.g. "v1.2.3", "Sprint 42").
90    /// `None` when not set or not supported by the tracker.
91    #[serde(default)]
92    pub version: Option<String>,
93    /// Start date of the ticket in `YYYY-MM-DD` format.
94    /// `None` when not set or not supported by the tracker.
95    #[serde(default)]
96    pub start_date: Option<String>,
97    /// Completion percentage as reported by the tracker (0–100).
98    /// `None` when not set or not supported by the tracker.
99    #[serde(default)]
100    pub done_ratio: Option<u32>,
101}
102
103/// A time-tracking activity category as defined in the tracker (e.g. Redmine enumerations).
104///
105/// Activities are fetched once at startup and cached in `App` to populate
106/// the Log Time popup selector without a network round-trip per keypress.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct Activity {
109    /// Numeric identifier used when submitting a time entry via the API.
110    pub id: u32,
111    /// Human-readable label shown in the popup selector (e.g. "Development", "Design").
112    pub name: String,
113}
114
115/// A single time entry recorded on a tracker ticket.
116///
117/// Returned by `fetch_time_entries` and displayed in the Inspector's TimeLog view.
118#[derive(Debug, Clone)]
119pub struct TimeEntry {
120    /// Internal tracker identifier for this entry.
121    pub id: u64,
122    /// Duration in hours as stored by the tracker.
123    pub hours: f32,
124    /// Activity category associated with this entry.
125    pub activity: Activity,
126    /// Free-text comment left by the user when logging time.
127    pub comment: String,
128    /// Display name of the user who logged the time.
129    pub user: String,
130    /// Date on which the time was spent, in `YYYY-MM-DD` format.
131    pub spent_on: String,
132}
133
134/// Payload sent to `log_time` when the user confirms the Log Time popup.
135///
136/// All fields are required — the popup enforces them before submission.
137#[derive(Debug, Clone)]
138pub struct TimeEntryRequest {
139    /// Duration in hours (e.g. `1.5` for 1h30).
140    pub hours: f32,
141    /// Identifier of the selected activity category.
142    pub activity_id: u32,
143    /// Optional free-text comment. May be empty.
144    pub comment: String,
145    /// Date on which the time was spent, in `YYYY-MM-DD` format (defaults to today).
146    pub spent_on: String,
147}
148
149/// Represents a single field change detected between two versions of a [`LinkedTicket`].
150///
151/// # Design
152/// This enum is the **only** place in the codebase where "which fields are trackable"
153/// is declared. Adding a new tracked field (e.g. `Sprint`, `DoneRatio`) only requires:
154///   1. Adding a variant here.
155///   2. Adding a match arm in `LinkedTicket::diff`.
156///   3. Handling the new variant in the orchestrator's notification dispatch.
157///
158/// The orchestrator (`gitlab-tracker`) and notification plugin (`gitlab-tracker-notify`)
159/// never need to know about field names directly — they only receive `TicketChange` values.
160/// This satisfies OCP: providers (Redmine, Jira, …) and consumers (app, notify) are
161/// decoupled from the field enumeration.
162#[derive(Debug, Clone, PartialEq)]
163pub enum TicketChange {
164    /// The priority label changed (e.g. "Normal" → "High").
165    Priority { old: String, new: String },
166    /// The status label changed (e.g. "In Progress" → "Resolved").
167    Status { old: String, new: String },
168    /// The assignee changed (e.g. "Alice" → "Bob", or "Unassigned" when empty).
169    Assignee { old: String, new: String },
170    /// The target version/release changed (e.g. "v1.2" → "v1.3", or "None" when unset).
171    Version { old: String, new: String },
172    /// The completion percentage changed (0–100). Fires on both increase and decrease.
173    /// `old` and `new` are formatted as \"N%\" strings for display consistency.
174    DoneRatio { old: String, new: String },
175}
176
177impl TicketChange {
178    /// Returns a human-readable label for the changed field, suitable for notification summaries.
179    pub fn field_label(&self) -> &'static str {
180        match self {
181            TicketChange::Priority { .. } => "priority",
182            TicketChange::Status { .. } => "status",
183            TicketChange::Assignee { .. } => "assignee",
184            TicketChange::Version { .. } => "version",
185            TicketChange::DoneRatio { .. } => "progress",
186        }
187    }
188
189    /// Returns the before/after values as `(&str, &str)` for display purposes.
190    pub fn before_after(&self) -> (&str, &str) {
191        match self {
192            TicketChange::Priority { old, new }
193            | TicketChange::Status { old, new }
194            | TicketChange::Assignee { old, new }
195            | TicketChange::Version { old, new }
196            | TicketChange::DoneRatio { old, new } => (old.as_str(), new.as_str()),
197        }
198    }
199}
200
201impl LinkedTicket {
202    /// Computes the list of tracked field changes between `self` (old) and `new`.
203    ///
204    /// Returns an empty `Vec` when nothing changed. The caller (orchestrator) should
205    /// iterate over the result and dispatch notifications for each entry.
206    ///
207    /// # Design
208    /// This is a **pure function** — no side effects, no I/O. Adding a new tracked field
209    /// only requires adding a comparison block here and a variant to [`TicketChange`].
210    /// The orchestrator and notification plugin remain unchanged for existing fields.
211    pub fn diff(&self, new: &LinkedTicket) -> Vec<TicketChange> {
212        let mut changes = Vec::new();
213
214        // Helper: normalise an Option<&str> to a display string for comparison.
215        let opt_str =
216            |v: Option<&str>, fallback: &str| -> String { v.unwrap_or(fallback).to_string() };
217
218        // Priority
219        let old_priority = opt_str(self.priority.as_deref(), "None");
220        let new_priority = opt_str(new.priority.as_deref(), "None");
221        if old_priority != new_priority {
222            changes.push(TicketChange::Priority {
223                old: old_priority,
224                new: new_priority,
225            });
226        }
227
228        // Status
229        if self.status != new.status {
230            changes.push(TicketChange::Status {
231                old: self.status.clone(),
232                new: new.status.clone(),
233            });
234        }
235
236        // Assignee
237        let old_assignee = opt_str(self.assignee.as_deref(), "Unassigned");
238        let new_assignee = opt_str(new.assignee.as_deref(), "Unassigned");
239        if old_assignee != new_assignee {
240            changes.push(TicketChange::Assignee {
241                old: old_assignee,
242                new: new_assignee,
243            });
244        }
245
246        // Version / release
247        let old_version = opt_str(self.version.as_deref(), "None");
248        let new_version = opt_str(new.version.as_deref(), "None");
249        if old_version != new_version {
250            changes.push(TicketChange::Version {
251                old: old_version,
252                new: new_version,
253            });
254        }
255
256        // Completion percentage — fires on both increase and decrease.
257        // Formatted as "N%" so the notification body is immediately readable.
258        let old_ratio = self.done_ratio.unwrap_or(0);
259        let new_ratio = new.done_ratio.unwrap_or(0);
260        if old_ratio != new_ratio {
261            changes.push(TicketChange::DoneRatio {
262                old: format!("{}%", old_ratio),
263                new: format!("{}%", new_ratio),
264            });
265        }
266
267        changes
268    }
269}
270
271/// Raw colour maps for badge labels, expressed as plain `(bg, fg)` string pairs.
272///
273/// Strings use the same vocabulary as `AppConfig::parse_color` in `gitlab-tracker`:
274/// named colours (`"red"`, `"cyan"`, `"dark_gray"`, …) or 6-digit hex (`"#ff6600"`).
275///
276/// This type lives in `core` so every provider can return it without depending on
277/// `ratatui`. The orchestrator (`gitlab-tracker`) is responsible for converting the
278/// strings to `ratatui::Color` values via its own `parse_color` function.
279#[derive(Debug, Clone, Default)]
280pub struct LabelColorMaps {
281    /// Colour map for tracker-type labels (e.g. "Bug", "Evolution").
282    /// Keys are matched case-insensitively by the renderer; `"*"` is a catch-all fallback.
283    pub tracker_type: HashMap<String, (String, String)>,
284    /// Colour map for priority labels (e.g. "Normal", "High").
285    /// Keys are matched case-insensitively by the renderer; `"*"` is a catch-all fallback.
286    pub priority: HashMap<String, (String, String)>,
287}
288
289/// Contract that every external tracker integration must implement.
290///
291/// # Design
292/// - The orchestrator holds a `Box<dyn TrackerProvider + Send + Sync>` and
293///   calls `detect_ticket_id` + `fetch_ticket` without knowing the concrete type.
294/// - Each implementation lives in its own crate (e.g. `gitlab-tracker-redmine`).
295/// - Adding a new tracker (Jira, Trello, Linear …) only requires a new crate
296///   that implements this trait — no change to the orchestrator logic.
297///
298/// # Thread safety
299/// `Send + Sync` is required because the provider is shared across async tasks.
300#[async_trait]
301pub trait TrackerProvider: Send + Sync {
302    /// Human-readable name of the tracker (e.g. "Redmine", "Jira").
303    /// Used for logging and UI labels.
304    fn name(&self) -> &'static str;
305
306    /// Attempts to extract a ticket identifier from the MR title and/or description.
307    ///
308    /// Returns the raw ticket id string (e.g. "1234") when found, `None` otherwise.
309    /// Implementations should prefer the title over the description when both match.
310    fn detect_ticket_id(&self, title: &str, description: &str) -> Option<String>;
311
312    /// Fetches the full ticket details for the given raw ticket id.
313    ///
314    /// Returns `None` on network error, authentication failure, or when the
315    /// ticket does not exist. The caller is responsible for caching results.
316    async fn fetch_ticket(&self, ticket_id: &str) -> Option<LinkedTicket>;
317
318    /// Builds the direct URL to the ticket from its id.
319    ///
320    /// This is a pure helper that may be called without a network round-trip
321    /// (e.g. to open the browser immediately on keypress while the fetch is pending).
322    fn ticket_url(&self, ticket_id: &str) -> String;
323
324    /// Returns the badge colour maps for tracker-type and priority labels.
325    ///
326    /// The returned strings are colour names or hex codes understood by the
327    /// orchestrator's `parse_color` function (`"red"`, `"#ff6600"`, …).
328    ///
329    /// Default implementation returns empty maps — the renderer then falls back
330    /// to its hard-coded default (dark_gray background, white text).
331    /// Override this in your provider to expose your config file's colour settings.
332    fn label_colors(&self) -> LabelColorMaps {
333        LabelColorMaps::default()
334    }
335
336    /// Fetches the list of time-tracking activity categories available in the tracker.
337    ///
338    /// Called once at startup (or on first popup open) and cached in `App`.
339    /// Default implementation returns an empty list (opt-in capability).
340    async fn fetch_activities(&self) -> Vec<Activity> {
341        vec![]
342    }
343
344    /// Fetches all time entries recorded on the given ticket.
345    ///
346    /// Displayed in the Inspector's TimeLog view. Default returns empty (opt-in).
347    async fn fetch_time_entries(&self, _ticket_id: &str) -> Vec<TimeEntry> {
348        vec![]
349    }
350
351    /// Submits a new time entry on the given ticket.
352    ///
353    /// Returns `Ok(())` on success, or a [`TrackerError`] describing the failure.
354    /// Default returns `TrackerError::Unsupported` — opt-in capability.
355    async fn log_time(
356        &self,
357        _ticket_id: &str,
358        _entry: TimeEntryRequest,
359    ) -> Result<(), TrackerError> {
360        Err(TrackerError::Unsupported(
361            "Time logging not supported by this tracker".into(),
362        ))
363    }
364}