Skip to main content

gitlab_tracker_redmine/
lib.rs

1mod client;
2mod detector;
3
4pub mod columns;
5pub mod config;
6pub mod filters;
7pub mod keyring;
8pub mod shortcuts;
9
10use async_trait::async_trait;
11use gitlab_tracker_core::LINKED_TICKET_SCHEMA_VERSION;
12use gitlab_tracker_core::{
13    Activity, LabelColorMaps, LinkedTicket, TimeEntry, TimeEntryRequest, TrackerProvider,
14};
15
16pub use config::RedmineConfig;
17pub use keyring::get_or_prompt_token;
18
19/// Redmine implementation of the [`TrackerProvider`] contract.
20///
21/// Constructed once at startup and shared via `Arc<dyn TrackerProvider>`.
22/// Requires a valid API token and a populated [`RedmineConfig`].
23pub struct RedmineProvider {
24    config: RedmineConfig,
25    /// Redmine API token — stored as a plain `String` here because `Arc<dyn TrackerProvider>`
26    /// requires `Sync`, and `Zeroizing<String>` is `Sync`. We clone it from the
27    /// `Zeroizing` wrapper immediately after the keyring lookup in the caller.
28    token: String,
29    /// Pre-built HTTP client — reused across all requests (connection pooling).
30    http: reqwest::Client,
31}
32
33impl RedmineProvider {
34    /// Creates a new [`RedmineProvider`] from a loaded config and a token.
35    pub fn new(config: RedmineConfig, token: String) -> Self {
36        Self {
37            config,
38            token,
39            http: reqwest::Client::new(),
40        }
41    }
42}
43
44#[async_trait]
45impl TrackerProvider for RedmineProvider {
46    fn name(&self) -> &'static str {
47        "Redmine"
48    }
49
50    fn detect_ticket_id(&self, title: &str, description: &str) -> Option<String> {
51        detector::detect_ticket_id(title, description, &self.config.ticket_patterns)
52    }
53
54    /// Exposes the colour maps configured in `redmine.yaml` to the orchestrator.
55    ///
56    /// The orchestrator converts the raw strings to `ratatui::Color` values — this
57    /// crate stays free of any UI/rendering dependency.
58    fn label_colors(&self) -> LabelColorMaps {
59        let to_map = |source: &std::collections::HashMap<String, config::LabelColorConfig>| {
60            source
61                .iter()
62                .map(|(k, cfg)| {
63                    // Store wildcard as-is; all other keys are lowercased so the
64                    // renderer's case-insensitive lookup can use a plain HashMap::get.
65                    let key = if k == "*" {
66                        k.clone()
67                    } else {
68                        k.to_lowercase()
69                    };
70                    (key, (cfg.bg.clone(), cfg.fg.clone()))
71                })
72                .collect()
73        };
74
75        LabelColorMaps {
76            tracker_type: to_map(&self.config.tracker_type_colors),
77            priority: to_map(&self.config.priority_colors),
78        }
79    }
80
81    async fn fetch_ticket(&self, ticket_id: &str) -> Option<LinkedTicket> {
82        let issue =
83            client::fetch_issue(&self.http, &self.config.url, &self.token, ticket_id).await?;
84
85        Some(LinkedTicket {
86            schema_version: LINKED_TICKET_SCHEMA_VERSION,
87            id: issue.id.to_string(),
88            subject: issue.subject,
89            status: issue.status.name,
90            url: self.ticket_url(ticket_id),
91            author: issue.author.map(|u| u.name),
92            assignee: issue.assigned_to.map(|u| u.name),
93            // Redmine returns hours — convert to seconds for the generic LinkedTicket contract.
94            time_estimate: issue.estimated_hours.map(|h| (h * 3600.0).round() as u32),
95            time_spent: issue.spent_hours.map(|h| (h * 3600.0).round() as u32),
96            time_remaining: issue.remaining_hours.map(|h| (h * 3600.0).round() as u32),
97            tracker_type: issue.tracker.map(|t| t.name),
98            priority: issue.priority.map(|p| p.name),
99            version: issue.fixed_version.map(|v| v.name),
100            start_date: issue.start_date,
101            done_ratio: issue.done_ratio,
102        })
103    }
104
105    fn ticket_url(&self, ticket_id: &str) -> String {
106        format!(
107            "{}/issues/{}",
108            self.config.url.trim_end_matches('/'),
109            ticket_id
110        )
111    }
112
113    /// Fetches all available time-tracking activity categories from Redmine.
114    ///
115    /// Delegates to `GET /enumerations/time_entry_activities.json`.
116    async fn fetch_activities(&self) -> Vec<Activity> {
117        client::fetch_activities(&self.http, &self.config.url, &self.token).await
118    }
119
120    /// Fetches all time entries recorded on a Redmine issue.
121    ///
122    /// Delegates to `GET /time_entries.json?issue_id={id}`.
123    async fn fetch_time_entries(&self, ticket_id: &str) -> Vec<TimeEntry> {
124        client::fetch_time_entries(&self.http, &self.config.url, &self.token, ticket_id).await
125    }
126
127    /// Submits a new time entry on the given Redmine issue.
128    ///
129    /// Fetches the issue beforehand to attach `budget_hours` and `remaining_hours`
130    /// (ETC) directly to the time entry payload — as required by the Redmine Budget
131    /// plugin. Both fields are omitted when the issue does not expose them (vanilla
132    /// Redmine). The fetch failure is non-fatal: submission proceeds without them.
133    ///
134    /// Delegates to `POST /time_entries.json`.
135    async fn log_time(
136        &self,
137        ticket_id: &str,
138        entry: TimeEntryRequest,
139    ) -> Result<(), gitlab_tracker_core::TrackerError> {
140        // Fetch the issue to get remaining_hours / estimated_hours for ETC computation.
141        // A None here simply means the ETC update step will be skipped — not an error.
142        let issue = client::fetch_issue(&self.http, &self.config.url, &self.token, ticket_id).await;
143
144        client::log_time(
145            &self.http,
146            &self.config.url,
147            &self.token,
148            ticket_id,
149            entry,
150            issue.as_ref(),
151        )
152        .await
153        .map_err(gitlab_tracker_core::TrackerError::Other)
154    }
155}