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