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}