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}