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::{Input, theme::ColorfulTheme};
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                && let Some(push_data) = event.push_data
247                && let Some(commit_to) = push_data.commit_to
248            {
249                // Fetch detailed commit information
250                let commit_detail = match self.get_commit_detail(event.project_id, &commit_to).await {
251                    Ok(detail) => detail,
252                    Err(_) => continue, // Skip commits that can't be fetched
253                };
254
255                // Extract commit message (first line only for task names)
256                let clean_message = commit_detail
257                    .message
258                    .split_once('\n') // Split on first newline
259                    .map(|(part, _)| part) // Take first part (summary line)
260                    .unwrap_or(&commit_detail.message) // Use full message if no newline
261                    .to_string();
262
263                commits_info.push(CommitInfo {
264                    sha: commit_detail.id,
265                    message: clean_message,
266                });
267            }
268        }
269
270        Ok(commits_info)
271    }
272
273    /// Fetches detailed information for a specific commit.
274    ///
275    /// Retrieves the complete commit object from GitLab's commits API, including
276    /// the full commit message and metadata. This is used to get detailed information
277    /// about commits identified through the events API.
278    ///
279    /// # Arguments
280    ///
281    /// * `project_id` - Numeric ID of the GitLab project containing the commit
282    /// * `commit_sha` - SHA hash of the commit to retrieve
283    ///
284    /// # Returns
285    ///
286    /// * `Result<Commit>` - Complete commit information from GitLab
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if:
291    /// - The commit doesn't exist or isn't accessible
292    /// - Network request fails
293    /// - GitLab returns an unexpected response format
294    /// - The user lacks permission to access the project
295    ///
296    /// # API Endpoint
297    ///
298    /// `GET /api/v4/projects/{project_id}/repository/commits/{commit_sha}`
299    async fn get_commit_detail(&self, project_id: u32, commit_sha: &str) -> Result<Commit> {
300        let url = format!("{}/api/v4/projects/{}/repository/commits/{}", self.config.api_url, project_id, commit_sha);
301        let response = self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await?;
302
303        Ok(response.json::<Commit>().await?)
304    }
305}
306
307/// Configuration for GitLab API integration.
308///
309/// This structure holds the necessary information for connecting to GitLab
310/// instances, including both GitLab.com and self-hosted installations.
311///
312/// ## Security Notes
313///
314/// - Personal Access Tokens are stored in configuration files
315/// - Tokens should be generated with minimal required scopes (`read_user`, `read_repository`)
316/// - Consider using project-specific tokens for enhanced security
317/// - Tokens can be revoked through GitLab's interface if compromised
318///
319/// ## Supported Instances
320///
321/// - **GitLab.com**: Use `https://gitlab.com` as the API URL
322/// - **Self-hosted**: Use your instance URL (e.g., `https://gitlab.company.com`)
323/// - **GitLab Enterprise**: Same as self-hosted with enterprise features
324#[derive(Serialize, Deserialize, Clone, Debug)]
325pub struct GitLabConfig {
326    /// Personal Access Token for GitLab API authentication.
327    ///
328    /// This token must have the following scopes:
329    /// - `read_user`: To fetch user information and user ID
330    /// - `read_repository`: To access commit data and repository events
331    ///
332    /// Generate tokens at: GitLab → User Settings → Access Tokens
333    pub access_token: String,
334
335    /// Base URL of the GitLab instance.
336    ///
337    /// Examples:
338    /// - GitLab.com: `https://gitlab.com`
339    /// - Self-hosted: `https://gitlab.example.com`
340    /// - Local development: `http://localhost:8080`
341    ///
342    /// Do not include the `/api/v4` path - it will be added automatically.
343    pub api_url: String,
344}
345
346impl GitLabConfig {
347    /// Returns the configuration module metadata for GitLab.
348    ///
349    /// Used by the configuration system to identify and manage
350    /// GitLab-specific settings during interactive setup.
351    ///
352    /// # Returns
353    ///
354    /// A `ConfigModule` with GitLab identification information.
355    pub fn module() -> ConfigModule {
356        ConfigModule {
357            key: "gitlab".to_string(),
358            name: "GitLab".to_string(),
359        }
360    }
361
362    /// Runs an interactive configuration setup for GitLab integration.
363    ///
364    /// Prompts the user for GitLab instance URL and personal access token,
365    /// using existing configuration values as defaults if available. This method
366    /// provides a user-friendly way to configure GitLab integration during
367    /// initial setup or reconfiguration.
368    ///
369    /// ## Interactive Prompts
370    ///
371    /// 1. **Personal Access Token**: Prompts for GitLab PAT with hidden input
372    /// 2. **API URL**: Prompts for GitLab instance URL with validation
373    ///
374    /// Both prompts will show existing values as defaults if configuration
375    /// already exists, making it easy to update only specific values.
376    ///
377    /// # Arguments
378    ///
379    /// * `config` - Existing GitLab configuration to use as defaults (if any)
380    ///
381    /// # Returns
382    ///
383    /// * `Result<Self>` - New GitLab configuration with user input
384    ///
385    /// # Errors
386    ///
387    /// Returns an error if:
388    /// - Terminal input/output fails
389    /// - User cancels the configuration process
390    /// - Input validation fails
391    ///
392    /// # Example
393    ///
394    /// ```rust,no_run
395    /// let existing_config = Some(GitLabConfig {
396    ///     access_token: "glpat-old-token".to_string(),
397    ///     api_url: "https://gitlab.com".to_string(),
398    /// });
399    ///
400    /// let new_config = GitLabConfig::init(&existing_config)?;
401    /// ```
402    pub fn init(config: &Option<GitLabConfig>) -> Result<Self> {
403        // Use existing configuration as defaults, or create empty defaults
404        let config = config.clone().unwrap_or(Self {
405            access_token: "".to_string(),
406            api_url: "".to_string(),
407        });
408
409        // Display configuration module header
410        msg_print!(Message::ConfigModuleGitLab);
411
412        // Interactive configuration with existing values as defaults
413        Ok(Self {
414            access_token: Input::with_theme(&ColorfulTheme::default())
415                .with_prompt("Enter your GitLab private token")
416                .default(config.access_token)
417                .interact_text()?,
418            api_url: Input::with_theme(&ColorfulTheme::default())
419                .with_prompt("Enter the GitLab API URL")
420                .default(config.api_url)
421                .interact_text()?,
422        })
423    }
424}