Skip to main content

kasl/api/
gitlab.rs

1//! GitLab API client for fetching user activity and commit data.
2//!
3//! Provides integration with GitLab instances (both self-hosted and GitLab.com)
4//! to automatically discover and import development activities as tasks.
5//!
6//! ## Features
7//!
8//! - **Commit Discovery**: Automatically fetches today's commits for task generation
9//! - **User Activity**: Retrieves push events and commit details via GitLab API v4
10//! - **Error Resilience**: Gracefully handles network failures without crashing
11//! - **Multi-Instance Support**: Works with GitLab.com, self-hosted, and enterprise instances
12//!
13//! ## Usage
14//!
15//! ```rust,no_run
16//! # use kasl::api::gitlab::{GitLab, GitLabConfig};
17//! # async fn f() -> anyhow::Result<()> {
18//! let config = GitLabConfig {
19//!     access_token: "glpat-xxxxxxxxxxxxxxxxxxxx".to_string(),
20//!     api_url: "https://gitlab.com".to_string(),
21//! };
22//!
23//! let client = GitLab::new(&config);
24//! let commits = client.get_today_commits().await?;
25//! # Ok(())
26//! # }
27//! ```
28
29use crate::libs::config::ConfigModule;
30use crate::libs::messages::Message;
31use crate::{msg_error, msg_print};
32use anyhow::Result;
33use chrono::{DateTime, Duration, Local, NaiveDate};
34use dialoguer::{Input, theme::ColorfulTheme};
35use reqwest::Client;
36use serde::{Deserialize, Serialize};
37use std::collections::HashSet;
38
39/// GitLab API client for retrieving user activity and commit information.
40///
41/// This client handles authentication and data retrieval from GitLab instances,
42/// specifically focusing on user events and commit details that can be transformed
43/// into task entries for time tracking purposes.
44///
45/// The client is stateless and thread-safe, making it suitable for concurrent
46/// operations and long-running applications.
47#[derive(Debug)]
48pub struct GitLab {
49    /// HTTP client for making API requests with connection pooling
50    client: Client,
51    /// Configuration containing API endpoint and authentication details
52    config: GitLabConfig,
53}
54
55/// Represents a GitLab user event from the events API.
56///
57/// GitLab events capture various user activities including pushes, merges,
58/// comments, and other repository interactions. This structure focuses on
59/// push events which contain commit information.
60#[derive(Debug, Deserialize)]
61struct Event {
62    /// Type of action performed (e.g., "pushed to", "opened", "commented on")
63    action_name: String,
64    /// Additional data for push events, contains commit references
65    push_data: Option<PushData>,
66    /// GitLab project ID where the event occurred
67    project_id: u32,
68}
69
70/// Push event data containing commit references.
71///
72/// When a user pushes commits to a repository, GitLab includes additional
73/// metadata about the push operation. Only `commit_to` (the tip) is enough for
74/// single-commit pushes; multi-commit pushes also expose `commit_from` and
75/// `commit_count` so the full range can be expanded via the compare API.
76#[derive(Debug, Deserialize)]
77struct PushData {
78    /// SHA of the tip commit after the push
79    commit_to: Option<String>,
80    /// SHA of the tip before the push (exclusive start of the pushed range)
81    commit_from: Option<String>,
82    /// Number of commits included in the push
83    commit_count: Option<u32>,
84}
85
86/// Response from GitLab's repository compare API.
87#[derive(Debug, Deserialize)]
88struct CompareResult {
89    commits: Vec<Commit>,
90}
91
92/// Simplified commit information for task creation.
93///
94/// This structure represents the essential information extracted from GitLab
95/// commits that's needed for generating task entries. It focuses on human-readable
96/// content rather than technical Git metadata.
97#[derive(Debug)]
98pub struct CommitInfo {
99    /// Full SHA hash of the commit for unique identification
100    pub sha: String,
101    /// First line of the commit message (typically the summary)
102    pub message: String,
103}
104
105/// Detailed commit object returned by GitLab's commits / compare APIs.
106#[derive(Debug, Deserialize)]
107struct Commit {
108    /// Full SHA identifier of the commit
109    id: String,
110    /// Complete commit message including body and trailers
111    message: String,
112    /// Author email (used to keep only the current user's commits)
113    author_email: Option<String>,
114    /// Author display name (fallback when email is missing)
115    author_name: Option<String>,
116    /// When the commit was authored (ISO-8601)
117    authored_date: Option<String>,
118    /// When the commit was committed (fallback date filter)
119    committed_date: Option<String>,
120}
121
122/// GitLab user information for the authenticated account.
123#[derive(Debug, Deserialize)]
124struct User {
125    /// Numeric user identifier in GitLab
126    id: u32,
127    /// Primary email from `/user` (best match for commit author_email)
128    email: Option<String>,
129    /// Display name (fallback author match)
130    name: Option<String>,
131}
132
133impl GitLab {
134    /// Creates a new GitLab API client instance.
135    ///
136    /// Initializes the HTTP client with default settings suitable for GitLab API
137    /// interactions. The client is configured for JSON responses and includes
138    /// reasonable timeout settings.
139    ///
140    /// # Arguments
141    ///
142    /// * `config` - GitLab configuration containing API endpoint and authentication token
143    ///
144    /// # Example
145    ///
146    /// ```rust,no_run
147    /// # use kasl::api::gitlab::{GitLab, GitLabConfig};
148    /// let config = GitLabConfig {
149    ///     access_token: "glpat-xxxxxxxxxxxxxxxxxxxx".to_string(),
150    ///     api_url: "https://gitlab.example.com".to_string(),
151    /// };
152    /// let client = GitLab::new(&config);
153    /// ```
154    pub fn new(config: &GitLabConfig) -> Self {
155        Self {
156            client: Client::new(),
157            config: config.clone(),
158        }
159    }
160
161    /// Retrieves the current user's GitLab ID.
162    ///
163    /// Makes a request to GitLab's `/user` endpoint to fetch the authenticated user's
164    /// information. The user ID is required for subsequent calls to the events API.
165    ///
166    /// # Returns
167    ///
168    /// * `Result<u32>` - The numeric user ID on success
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if:
173    /// - Network request fails
174    /// - Authentication token is invalid
175    /// - GitLab returns an unexpected response format
176    ///
177    /// # API Endpoint
178    ///
179    /// `GET /api/v4/user` - Requires `read_user` scope
180    pub async fn get_user_id(&self) -> Result<u32> {
181        Ok(self.get_current_user().await?.id)
182    }
183
184    /// Fetches the authenticated GitLab user (`/user`).
185    async fn get_current_user(&self) -> Result<User> {
186        let url = format!("{}/api/v4/user", self.config.api_url);
187        let response = self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await?;
188
189        Ok(response.json::<User>().await?)
190    }
191
192    /// Fetches all commits made by the authenticated user today.
193    ///
194    /// This is the primary method for discovering development activity that can be
195    /// converted into time tracking tasks. It retrieves user events from yesterday
196    /// to tomorrow (to handle timezone issues) and filters for push events containing
197    /// commit information.
198    ///
199    /// ## Process Flow
200    ///
201    /// 1. **Date Range Calculation**: Creates a date range around today to handle timezone differences
202    /// 2. **User ID Retrieval**: Gets the authenticated user's ID for events API
203    /// 3. **Events Fetching**: Retrieves user events within the date range
204    /// 4. **Event Filtering**: Processes only "pushed to" events with commit data
205    /// 5. **Commit Details**: Fetches detailed commit information for each push
206    /// 6. **Message Processing**: Extracts and cleans commit messages for task names
207    ///
208    /// ## Error Resilience
209    ///
210    /// This method is designed to be fault-tolerant in production environments:
211    /// - Network failures return empty results instead of errors
212    /// - Individual commit fetch failures are skipped
213    /// - API parsing errors are logged and don't interrupt processing
214    /// - Missing or malformed data is handled gracefully
215    ///
216    /// # Returns
217    ///
218    /// * `Result<Vec<CommitInfo>>` - List of today's commits, or empty vector on any error
219    ///
220    /// # Example
221    ///
222    /// ```text
223    /// let commits = gitlab_client.get_today_commits().await?;
224    /// for commit in commits {
225    ///     println!("Commit {}: {}", commit.sha, commit.message);
226    /// }
227    /// ```
228    pub async fn get_today_commits(&self) -> Result<Vec<CommitInfo>> {
229        // Events window is wider (yesterday..tomorrow) to absorb timezone skew;
230        // individual commits are then filtered to local today + current author.
231        let today = Local::now();
232        let today_date = today.date_naive();
233        let yesterday = (today - Duration::days(1)).format("%Y-%m-%d").to_string();
234        let tomorrow = (today + Duration::days(1)).format("%Y-%m-%d").to_string();
235
236        let user = self.get_current_user().await.inspect_err(|e| {
237            msg_error!(Message::GitlabUserIdFailed(e.to_string()));
238        })?;
239
240        let events = self.fetch_user_events(user.id, &yesterday, &tomorrow).await?;
241
242        // Expand each push to all commits in the range (not only the tip).
243        let mut commits_info = Vec::new();
244        let mut seen_shas = HashSet::new();
245
246        for event in events {
247            if !matches!(event.action_name.as_str(), "pushed to" | "pushed new") {
248                continue;
249            }
250            let Some(push_data) = event.push_data else {
251                continue;
252            };
253
254            let commits = match self.commits_for_push(event.project_id, &push_data).await {
255                Ok(c) => c,
256                Err(_) => continue,
257            };
258
259            for commit in commits {
260                if !is_commit_by_user(&commit, &user) {
261                    continue;
262                }
263                if !is_commit_on_date(&commit, today_date) {
264                    continue;
265                }
266                if !seen_shas.insert(commit.id.clone()) {
267                    continue;
268                }
269                let clean_message = commit.message.split_once('\n').map(|(part, _)| part).unwrap_or(&commit.message).to_string();
270
271                commits_info.push(CommitInfo {
272                    sha: commit.id,
273                    message: clean_message,
274                });
275            }
276        }
277
278        Ok(commits_info)
279    }
280
281    /// Fetches all pages of user events in the given date window.
282    async fn fetch_user_events(&self, user_id: u32, after: &str, before: &str) -> Result<Vec<Event>> {
283        let mut all = Vec::new();
284        let mut page: u32 = 1;
285
286        loop {
287            let url = format!("{}/api/v4/users/{}/events", self.config.api_url, user_id);
288            let response = self
289                .client
290                .get(&url)
291                .header("PRIVATE-TOKEN", &self.config.access_token)
292                .query(&[("after", after), ("before", before), ("per_page", "100"), ("page", &page.to_string())])
293                .send()
294                .await?;
295
296            if !response.status().is_success() {
297                let status = response.status();
298                let body = response.text().await.unwrap_or_default();
299                anyhow::bail!("GitLab events request failed: HTTP {status}: {body}");
300            }
301
302            let batch: Vec<Event> = response.json().await?;
303            let batch_len = batch.len();
304            all.extend(batch);
305
306            if batch_len < 100 {
307                break;
308            }
309            page += 1;
310        }
311
312        Ok(all)
313    }
314
315    /// Returns every commit included in a push event.
316    ///
317    /// Single-commit pushes use the tip (`commit_to`). Multi-commit pushes use
318    /// GitLab's compare API between `commit_from` and `commit_to`, otherwise only
319    /// the merge/tip message would be visible to task discovery.
320    async fn commits_for_push(&self, project_id: u32, push: &PushData) -> Result<Vec<Commit>> {
321        let Some(commit_to) = push.commit_to.as_deref() else {
322            return Ok(Vec::new());
323        };
324
325        let count = push.commit_count.unwrap_or(1);
326        if count > 1
327            && let Some(commit_from) = push.commit_from.as_deref()
328            && !is_null_sha(commit_from)
329            && commit_from != commit_to
330        {
331            match self.compare_commits(project_id, commit_from, commit_to).await {
332                Ok(commits) if !commits.is_empty() => return Ok(commits),
333                _ => {}
334            }
335        }
336
337        Ok(vec![self.get_commit_detail(project_id, commit_to).await?])
338    }
339
340    /// Fetches commits in `(from, to]` via the repository compare API.
341    async fn compare_commits(&self, project_id: u32, from: &str, to: &str) -> Result<Vec<Commit>> {
342        let url = format!("{}/api/v4/projects/{}/repository/compare", self.config.api_url, project_id);
343        let response = self
344            .client
345            .get(&url)
346            .header("PRIVATE-TOKEN", &self.config.access_token)
347            .query(&[("from", from), ("to", to)])
348            .send()
349            .await?;
350
351        if !response.status().is_success() {
352            let status = response.status();
353            let body = response.text().await.unwrap_or_default();
354            anyhow::bail!("GitLab compare failed: HTTP {status}: {body}");
355        }
356
357        Ok(response.json::<CompareResult>().await?.commits)
358    }
359
360    /// Fetches detailed information for a specific commit.
361    ///
362    /// Retrieves the complete commit object from GitLab's commits API, including
363    /// the full commit message and metadata. This is used to get detailed information
364    /// about commits identified through the events API.
365    ///
366    /// # Arguments
367    ///
368    /// * `project_id` - Numeric ID of the GitLab project containing the commit
369    /// * `commit_sha` - SHA hash of the commit to retrieve
370    ///
371    /// # Returns
372    ///
373    /// * `Result<Commit>` - Complete commit information from GitLab
374    ///
375    /// # Errors
376    ///
377    /// Returns an error if:
378    /// - The commit doesn't exist or isn't accessible
379    /// - Network request fails
380    /// - GitLab returns an unexpected response format
381    /// - The user lacks permission to access the project
382    ///
383    /// # API Endpoint
384    ///
385    /// `GET /api/v4/projects/{project_id}/repository/commits/{commit_sha}`
386    async fn get_commit_detail(&self, project_id: u32, commit_sha: &str) -> Result<Commit> {
387        let url = format!("{}/api/v4/projects/{}/repository/commits/{}", self.config.api_url, project_id, commit_sha);
388        let response = self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await?;
389
390        Ok(response.json::<Commit>().await?)
391    }
392}
393
394/// Returns true for GitLab's all-zero "no parent" SHA used on new branches.
395fn is_null_sha(sha: &str) -> bool {
396    !sha.is_empty() && sha.bytes().all(|b| b == b'0')
397}
398
399/// True when the commit author matches the authenticated GitLab user.
400///
401/// Prefers email (stable across display-name spelling); falls back to name.
402fn is_commit_by_user(commit: &Commit, user: &User) -> bool {
403    if let (Some(commit_email), Some(user_email)) = (&commit.author_email, &user.email)
404        && !user_email.is_empty()
405        && commit_email.eq_ignore_ascii_case(user_email)
406    {
407        return true;
408    }
409
410    if let (Some(commit_name), Some(user_name)) = (&commit.author_name, &user.name)
411        && !user_name.is_empty()
412        && commit_name.eq_ignore_ascii_case(user_name)
413    {
414        return true;
415    }
416
417    false
418}
419
420/// True when the commit was authored on `date` (local TZ).
421///
422/// Falls back to `committed_date` only when `authored_date` is missing.
423fn is_commit_on_date(commit: &Commit, date: NaiveDate) -> bool {
424    let raw = commit.authored_date.as_deref().or(commit.committed_date.as_deref());
425    let Some(raw) = raw else {
426        return false;
427    };
428    DateTime::parse_from_rfc3339(raw)
429        .map(|dt| dt.with_timezone(&Local).date_naive() == date)
430        .unwrap_or(false)
431}
432
433/// Configuration for GitLab API integration.
434///
435/// This structure holds the necessary information for connecting to GitLab
436/// instances, including both GitLab.com and self-hosted installations.
437///
438/// ## Security Notes
439///
440/// - Personal Access Tokens are stored in configuration files
441/// - Tokens should be generated with minimal required scopes (`read_user`, `read_repository`)
442/// - Consider using project-specific tokens for enhanced security
443/// - Tokens can be revoked through GitLab's interface if compromised
444///
445/// ## Supported Instances
446///
447/// - **GitLab.com**: Use `https://gitlab.com` as the API URL
448/// - **Self-hosted**: Use your instance URL (e.g., `https://gitlab.company.com`)
449/// - **GitLab Enterprise**: Same as self-hosted with enterprise features
450#[derive(Serialize, Deserialize, Clone, Debug)]
451pub struct GitLabConfig {
452    /// Personal Access Token for GitLab API authentication.
453    ///
454    /// This token must have the following scopes:
455    /// - `read_user`: To fetch user information and user ID
456    /// - `read_repository`: To access commit data and repository events
457    ///
458    /// Generate tokens at: GitLab → User Settings → Access Tokens
459    pub access_token: String,
460
461    /// Base URL of the GitLab instance.
462    ///
463    /// Examples:
464    /// - GitLab.com: `https://gitlab.com`
465    /// - Self-hosted: `https://gitlab.example.com`
466    /// - Local development: `http://localhost:8080`
467    ///
468    /// Do not include the `/api/v4` path - it will be added automatically.
469    pub api_url: String,
470}
471
472impl GitLabConfig {
473    /// Returns the configuration module metadata for GitLab.
474    ///
475    /// Used by the configuration system to identify and manage
476    /// GitLab-specific settings during interactive setup.
477    ///
478    /// # Returns
479    ///
480    /// A `ConfigModule` with GitLab identification information.
481    pub fn module() -> ConfigModule {
482        ConfigModule {
483            key: "gitlab".to_string(),
484            name: "GitLab".to_string(),
485        }
486    }
487
488    /// Runs an interactive configuration setup for GitLab integration.
489    ///
490    /// Prompts the user for GitLab instance URL and personal access token,
491    /// using existing configuration values as defaults if available. This method
492    /// provides a user-friendly way to configure GitLab integration during
493    /// initial setup or reconfiguration.
494    ///
495    /// ## Interactive Prompts
496    ///
497    /// 1. **Personal Access Token**: Prompts for GitLab PAT with hidden input
498    /// 2. **API URL**: Prompts for GitLab instance URL with validation
499    ///
500    /// Both prompts will show existing values as defaults if configuration
501    /// already exists, making it easy to update only specific values.
502    ///
503    /// # Arguments
504    ///
505    /// * `config` - Existing GitLab configuration to use as defaults (if any)
506    ///
507    /// # Returns
508    ///
509    /// * `Result<Self>` - New GitLab configuration with user input
510    ///
511    /// # Errors
512    ///
513    /// Returns an error if:
514    /// - Terminal input/output fails
515    /// - User cancels the configuration process
516    /// - Input validation fails
517    ///
518    /// # Example
519    ///
520    /// ```rust,no_run
521    /// # use kasl::api::gitlab::GitLabConfig;
522    /// # fn f() -> anyhow::Result<()> {
523    /// let existing_config = Some(GitLabConfig {
524    ///     access_token: "glpat-old-token".to_string(),
525    ///     api_url: "https://gitlab.com".to_string(),
526    /// });
527    ///
528    /// let new_config = GitLabConfig::init(&existing_config)?;
529    /// # Ok(())
530    /// # }
531    /// ```
532    pub fn init(config: &Option<GitLabConfig>) -> Result<Self> {
533        // Use existing configuration as defaults, or create empty defaults
534        let config = config.clone().unwrap_or(Self {
535            access_token: "".to_string(),
536            api_url: "".to_string(),
537        });
538
539        // Display configuration module header
540        msg_print!(Message::ConfigModuleGitLab);
541
542        // Interactive configuration with existing values as defaults
543        Ok(Self {
544            access_token: Input::with_theme(&ColorfulTheme::default())
545                .with_prompt("Enter your GitLab private token")
546                .default(config.access_token)
547                .interact_text()?,
548            api_url: Input::with_theme(&ColorfulTheme::default())
549                .with_prompt("Enter the GitLab API URL")
550                .default(config.api_url)
551                .interact_text()?,
552        })
553    }
554}