Skip to main content

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/// Raw colour maps for badge labels, expressed as plain `(bg, fg)` string pairs.
126///
127/// Strings use the same vocabulary as `AppConfig::parse_color` in `gitlab-tracker`:
128/// named colours (`"red"`, `"cyan"`, `"dark_gray"`, …) or 6-digit hex (`"#ff6600"`).
129///
130/// This type lives in `core` so every provider can return it without depending on
131/// `ratatui`. The orchestrator (`gitlab-tracker`) is responsible for converting the
132/// strings to `ratatui::Color` values via its own `parse_color` function.
133#[derive(Debug, Clone, Default)]
134pub struct LabelColorMaps {
135    /// Colour map for tracker-type labels (e.g. "Bug", "Evolution").
136    /// Keys are matched case-insensitively by the renderer; `"*"` is a catch-all fallback.
137    pub tracker_type: HashMap<String, (String, String)>,
138    /// Colour map for priority labels (e.g. "Normal", "High").
139    /// Keys are matched case-insensitively by the renderer; `"*"` is a catch-all fallback.
140    pub priority: HashMap<String, (String, String)>,
141}
142
143/// Contract that every external tracker integration must implement.
144///
145/// # Design
146/// - The orchestrator holds a `Box<dyn TrackerProvider + Send + Sync>` and
147///   calls `detect_ticket_id` + `fetch_ticket` without knowing the concrete type.
148/// - Each implementation lives in its own crate (e.g. `gitlab-tracker-redmine`).
149/// - Adding a new tracker (Jira, Trello, Linear …) only requires a new crate
150///   that implements this trait — no change to the orchestrator logic.
151///
152/// # Thread safety
153/// `Send + Sync` is required because the provider is shared across async tasks.
154#[async_trait]
155pub trait TrackerProvider: Send + Sync {
156    /// Human-readable name of the tracker (e.g. "Redmine", "Jira").
157    /// Used for logging and UI labels.
158    fn name(&self) -> &'static str;
159
160    /// Attempts to extract a ticket identifier from the MR title and/or description.
161    ///
162    /// Returns the raw ticket id string (e.g. "1234") when found, `None` otherwise.
163    /// Implementations should prefer the title over the description when both match.
164    fn detect_ticket_id(&self, title: &str, description: &str) -> Option<String>;
165
166    /// Fetches the full ticket details for the given raw ticket id.
167    ///
168    /// Returns `None` on network error, authentication failure, or when the
169    /// ticket does not exist. The caller is responsible for caching results.
170    async fn fetch_ticket(&self, ticket_id: &str) -> Option<LinkedTicket>;
171
172    /// Builds the direct URL to the ticket from its id.
173    ///
174    /// This is a pure helper that may be called without a network round-trip
175    /// (e.g. to open the browser immediately on keypress while the fetch is pending).
176    fn ticket_url(&self, ticket_id: &str) -> String;
177
178    /// Returns the badge colour maps for tracker-type and priority labels.
179    ///
180    /// The returned strings are colour names or hex codes understood by the
181    /// orchestrator's `parse_color` function (`"red"`, `"#ff6600"`, …).
182    ///
183    /// Default implementation returns empty maps — the renderer then falls back
184    /// to its hard-coded default (dark_gray background, white text).
185    /// Override this in your provider to expose your config file's colour settings.
186    fn label_colors(&self) -> LabelColorMaps {
187        LabelColorMaps::default()
188    }
189
190    /// Fetches the list of time-tracking activity categories available in the tracker.
191    ///
192    /// Called once at startup (or on first popup open) and cached in `App`.
193    /// Default implementation returns an empty list (opt-in capability).
194    async fn fetch_activities(&self) -> Vec<Activity> {
195        vec![]
196    }
197
198    /// Fetches all time entries recorded on the given ticket.
199    ///
200    /// Displayed in the Inspector's TimeLog view. Default returns empty (opt-in).
201    async fn fetch_time_entries(&self, _ticket_id: &str) -> Vec<TimeEntry> {
202        vec![]
203    }
204
205    /// Submits a new time entry on the given ticket.
206    ///
207    /// Returns `Ok(())` on success, or an error message string suitable for
208    /// displaying inline in the TUI. Default returns an unsupported error.
209    async fn log_time(&self, _ticket_id: &str, _entry: TimeEntryRequest) -> Result<(), String> {
210        Err("Time logging not supported by this tracker".into())
211    }
212}