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//!
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//! ```
26
27use crate::libs::config::ConfigModule;
28use crate::libs::messages::Message;
29use crate::{msg_error, msg_print};
30use anyhow::Result;
31use chrono::{Duration, Local};
32use dialoguer::{theme::ColorfulTheme, Input};
33use reqwest::Client;
34use serde::{Deserialize, Serialize};
35
36/// GitLab API client for retrieving user activity and commit information.
37///
38/// This client handles authentication and data retrieval from GitLab instances,
39/// specifically focusing on user events and commit details that can be transformed
40/// into task entries for time tracking purposes.
41///
42/// The client is stateless and thread-safe, making it suitable for concurrent
43/// operations and long-running applications.
44#[derive(Debug)]
45pub struct GitLab {
46    /// HTTP client for making API requests with connection pooling
47    client: Client,
48    /// Configuration containing API endpoint and authentication details
49    config: GitLabConfig,
50}
51
52/// Represents a GitLab user event from the events API.
53///
54/// GitLab events capture various user activities including pushes, merges,
55/// comments, and other repository interactions. This structure focuses on
56/// push events which contain commit information.
57#[derive(Debug, Deserialize)]
58struct Event {
59    /// Type of action performed (e.g., "pushed to", "opened", "commented on")
60    action_name: String,
61    /// Additional data for push events, contains commit references
62    push_data: Option<PushData>,
63    /// GitLab project ID where the event occurred
64    project_id: u32,
65}
66
67/// Push event data containing commit references.
68///
69/// When a user pushes commits to a repository, GitLab includes additional
70/// metadata about the push operation, including the commit SHA that was
71/// pushed to the target branch.
72#[derive(Debug, Deserialize)]
73struct PushData {
74    /// SHA of the commit that was pushed (target commit)
75    commit_to: Option<String>,
76}
77
78/// Simplified commit information for task creation.
79///
80/// This structure represents the essential information extracted from GitLab
81/// commits that's needed for generating task entries. It focuses on human-readable
82/// content rather than technical Git metadata.
83#[derive(Debug)]
84pub struct CommitInfo {
85    /// Full SHA hash of the commit for unique identification
86    pub sha: String,
87    /// First line of the commit message (typically the summary)
88    pub message: String,
89}
90
91/// Detailed commit object returned by GitLab's commits API.
92///
93/// This represents the full commit information returned by GitLab's REST API
94/// when fetching specific commit details. Contains more information than needed
95/// for task creation, but provides access to the complete commit message.
96#[derive(Debug, Deserialize)]
97struct Commit {
98    /// Full SHA identifier of the commit
99    id: String,
100    /// Complete commit message including body and trailers
101    message: String,
102}
103
104/// GitLab user information for retrieving user ID.
105///
106/// Used to identify the current user when fetching user-specific events.
107/// GitLab's events API requires the numeric user ID rather than username.
108#[derive(Debug, Deserialize)]
109struct User {
110    /// Numeric user identifier in GitLab
111    id: u32,
112}
113
114impl GitLab {
115    /// Creates a new GitLab API client instance.
116    ///
117    /// Initializes the HTTP client with default settings suitable for GitLab API
118    /// interactions. The client is configured for JSON responses and includes
119    /// reasonable timeout settings.
120    ///
121    /// # Arguments
122    ///
123    /// * `config` - GitLab configuration containing API endpoint and authentication token
124    ///
125    /// # Example
126    ///
127    /// ```rust,no_run
128    /// let config = GitLabConfig {
129    ///     access_token: "glpat-xxxxxxxxxxxxxxxxxxxx".to_string(),
130    ///     api_url: "https://gitlab.example.com".to_string(),
131    /// };
132    /// let client = GitLab::new(&config);
133    /// ```
134    pub fn new(config: &GitLabConfig) -> Self {
135        Self {
136            client: Client::new(),
137            config: config.clone(),
138        }
139    }
140
141    /// Retrieves the current user's GitLab ID.
142    ///
143    /// Makes a request to GitLab's `/user` endpoint to fetch the authenticated user's
144    /// information. The user ID is required for subsequent calls to the events API.
145    ///
146    /// # Returns
147    ///
148    /// * `Result<u32>` - The numeric user ID on success
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if:
153    /// - Network request fails
154    /// - Authentication token is invalid
155    /// - GitLab returns an unexpected response format
156    ///
157    /// # API Endpoint
158    ///
159    /// `GET /api/v4/user` - Requires `read_user` scope
160    pub async fn get_user_id(&self) -> Result<u32> {
161        let url = format!("{}/api/v4/user", self.config.api_url);
162        let response = self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await?;
163
164        Ok(response.json::<User>().await?.id)
165    }
166
167    /// Fetches all commits made by the authenticated user today.
168    ///
169    /// This is the primary method for discovering development activity that can be
170    /// converted into time tracking tasks. It retrieves user events from yesterday
171    /// to tomorrow (to handle timezone issues) and filters for push events containing
172    /// commit information.
173    ///
174    /// ## Process Flow
175    ///
176    /// 1. **Date Range Calculation**: Creates a date range around today to handle timezone differences
177    /// 2. **User ID Retrieval**: Gets the authenticated user's ID for events API
178    /// 3. **Events Fetching**: Retrieves user events within the date range
179    /// 4. **Event Filtering**: Processes only "pushed to" events with commit data
180    /// 5. **Commit Details**: Fetches detailed commit information for each push
181    /// 6. **Message Processing**: Extracts and cleans commit messages for task names
182    ///
183    /// ## Error Resilience
184    ///
185    /// This method is designed to be fault-tolerant in production environments:
186    /// - Network failures return empty results instead of errors
187    /// - Individual commit fetch failures are skipped
188    /// - API parsing errors are logged and don't interrupt processing
189    /// - Missing or malformed data is handled gracefully
190    ///
191    /// # Returns
192    ///
193    /// * `Result<Vec<CommitInfo>>` - List of today's commits, or empty vector on any error
194    ///
195    /// # Example
196    ///
197    /// ```rust,no_run
198    /// let commits = gitlab_client.get_today_commits().await?;
199    /// for commit in commits {
200    ///     println!("Commit {}: {}", commit.sha, commit.message);
201    /// }
202    /// ```
203    pub async fn get_today_commits(&self) -> Result<Vec<CommitInfo>> {
204        // Calculate date range around today to handle timezone differences
205        let today = Local::now();
206        let yesterday = (today - Duration::days(1)).format("%Y-%m-%d").to_string();
207        let tomorrow = (today + Duration::days(1)).format("%Y-%m-%d").to_string();
208
209        // Get authenticated user's ID for events API
210        let user_id = match self.get_user_id().await {
211            Ok(id) => id,
212            Err(e) => {
213                msg_error!(Message::GitlabUserIdFailed(e.to_string()));
214                return Ok(Vec::new()); // Return empty on user ID failure
215            }
216        };
217
218        // Fetch user events within date range
219        let url = format!(
220            "{}/api/v4/users/{}/events?after={}&before={}",
221            self.config.api_url, user_id, yesterday, tomorrow
222        );
223
224        let response = match self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await {
225            Ok(res) => res,
226            Err(e) => {
227                msg_error!(Message::GitlabFetchFailed(e.to_string()));
228                return Ok(Vec::new()); // Return empty on request failure
229            }
230        };
231
232        // Parse events response
233        let events = match response.json::<Vec<Event>>().await {
234            Ok(ev) => ev,
235            Err(e) => {
236                msg_error!(Message::GitlabFetchFailed(e.to_string()));
237                return Ok(Vec::new()); // Return empty on parsing failure
238            }
239        };
240
241        // Process push events and collect commit information
242        let mut commits_info = Vec::new();
243        for event in events {
244            // Only process push events
245            if event.action_name == "pushed to" {
246                if let Some(push_data) = event.push_data {
247                    if let Some(commit_to) = push_data.commit_to {
248                        // Fetch detailed commit information
249                        let commit_detail = match self.get_commit_detail(event.project_id, &commit_to).await {
250                            Ok(detail) => detail,
251                            Err(_) => continue, // Skip commits that can't be fetched
252                        };
253
254                        // Extract commit message (first line only for task names)
255                        let clean_message = commit_detail
256                            .message
257                            .split_once('\n') // Split on first newline
258                            .map(|(part, _)| part) // Take first part (summary line)
259                            .unwrap_or(&commit_detail.message) // Use full message if no newline
260                            .to_string();
261
262                        commits_info.push(CommitInfo {
263                            sha: commit_detail.id,
264                            message: clean_message,
265                        });
266                    }
267                }
268            }
269        }
270
271        Ok(commits_info)
272    }
273
274    /// Fetches detailed information for a specific commit.
275    ///
276    /// Retrieves the complete commit object from GitLab's commits API, including
277    /// the full commit message and metadata. This is used to get detailed information
278    /// about commits identified through the events API.
279    ///
280    /// # Arguments
281    ///
282    /// * `project_id` - Numeric ID of the GitLab project containing the commit
283    /// * `commit_sha` - SHA hash of the commit to retrieve
284    ///
285    /// # Returns
286    ///
287    /// * `Result<Commit>` - Complete commit information from GitLab
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if:
292    /// - The commit doesn't exist or isn't accessible
293    /// - Network request fails
294    /// - GitLab returns an unexpected response format
295    /// - The user lacks permission to access the project
296    ///
297    /// # API Endpoint
298    ///
299    /// `GET /api/v4/projects/{project_id}/repository/commits/{commit_sha}`
300    async fn get_commit_detail(&self, project_id: u32, commit_sha: &str) -> Result<Commit> {
301        let url = format!("{}/api/v4/projects/{}/repository/commits/{}", self.config.api_url, project_id, commit_sha);
302        let response = self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await?;
303
304        Ok(response.json::<Commit>().await?)
305    }
306}
307
308/// Configuration for GitLab API integration.
309///
310/// This structure holds the necessary information for connecting to GitLab
311/// instances, including both GitLab.com and self-hosted installations.
312///
313/// ## Security Notes
314///
315/// - Personal Access Tokens are stored in configuration files
316/// - Tokens should be generated with minimal required scopes (`read_user`, `read_repository`)
317/// - Consider using project-specific tokens for enhanced security
318/// - Tokens can be revoked through GitLab's interface if compromised
319///
320/// ## Supported Instances
321///
322/// - **GitLab.com**: Use `https://gitlab.com` as the API URL
323/// - **Self-hosted**: Use your instance URL (e.g., `https://gitlab.company.com`)
324/// - **GitLab Enterprise**: Same as self-hosted with enterprise features
325#[derive(Serialize, Deserialize, Clone, Debug)]
326pub struct GitLabConfig {
327    /// Personal Access Token for GitLab API authentication.
328    ///
329    /// This token must have the following scopes:
330    /// - `read_user`: To fetch user information and user ID
331    /// - `read_repository`: To access commit data and repository events
332    ///
333    /// Generate tokens at: GitLab → User Settings → Access Tokens
334    pub access_token: String,
335
336    /// Base URL of the GitLab instance.
337    ///
338    /// Examples:
339    /// - GitLab.com: `https://gitlab.com`
340    /// - Self-hosted: `https://gitlab.example.com`
341    /// - Local development: `http://localhost:8080`
342    ///
343    /// Do not include the `/api/v4` path - it will be added automatically.
344    pub api_url: String,
345}
346
347impl GitLabConfig {
348    /// Returns the configuration module metadata for GitLab.
349    ///
350    /// Used by the configuration system to identify and manage
351    /// GitLab-specific settings during interactive setup.
352    ///
353    /// # Returns
354    ///
355    /// A `ConfigModule` with GitLab identification information.
356    pub fn module() -> ConfigModule {
357        ConfigModule {
358            key: "gitlab".to_string(),
359            name: "GitLab".to_string(),
360        }
361    }
362
363    /// Runs an interactive configuration setup for GitLab integration.
364    ///
365    /// Prompts the user for GitLab instance URL and personal access token,
366    /// using existing configuration values as defaults if available. This method
367    /// provides a user-friendly way to configure GitLab integration during
368    /// initial setup or reconfiguration.
369    ///
370    /// ## Interactive Prompts
371    ///
372    /// 1. **Personal Access Token**: Prompts for GitLab PAT with hidden input
373    /// 2. **API URL**: Prompts for GitLab instance URL with validation
374    ///
375    /// Both prompts will show existing values as defaults if configuration
376    /// already exists, making it easy to update only specific values.
377    ///
378    /// # Arguments
379    ///
380    /// * `config` - Existing GitLab configuration to use as defaults (if any)
381    ///
382    /// # Returns
383    ///
384    /// * `Result<Self>` - New GitLab configuration with user input
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if:
389    /// - Terminal input/output fails
390    /// - User cancels the configuration process
391    /// - Input validation fails
392    ///
393    /// # Example
394    ///
395    /// ```rust,no_run
396    /// let existing_config = Some(GitLabConfig {
397    ///     access_token: "glpat-old-token".to_string(),
398    ///     api_url: "https://gitlab.com".to_string(),
399    /// });
400    ///
401    /// let new_config = GitLabConfig::init(&existing_config)?;
402    /// ```
403    pub fn init(config: &Option<GitLabConfig>) -> Result<Self> {
404        // Use existing configuration as defaults, or create empty defaults
405        let config = config.clone().unwrap_or(Self {
406            access_token: "".to_string(),
407            api_url: "".to_string(),
408        });
409
410        // Display configuration module header
411        msg_print!(Message::ConfigModuleGitLab);
412
413        // Interactive configuration with existing values as defaults
414        Ok(Self {
415            access_token: Input::with_theme(&ColorfulTheme::default())
416                .with_prompt("Enter your GitLab private token")
417                .default(config.access_token)
418                .interact_text()?,
419            api_url: Input::with_theme(&ColorfulTheme::default())
420                .with_prompt("Enter the GitLab API URL")
421                .default(config.api_url)
422                .interact_text()?,
423        })
424    }
425}