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}