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//! ## Usage
7//!
8//! ```rust,no_run
9//! # use kasl::api::gitlab::{GitLab, GitLabConfig};
10//! # async fn f() -> anyhow::Result<()> {
11//! let config = GitLabConfig {
12//!     access_token: "glpat-xxxxxxxxxxxxxxxxxxxx".to_string(),
13//!     api_url: "https://gitlab.com".to_string(),
14//! };
15//!
16//! let client = GitLab::new(&config);
17//! let commits = client.get_today_commits().await?;
18//! # Ok(())
19//! # }
20//! ```
21
22use crate::libs::config::ConfigModule;
23use crate::libs::messages::Message;
24use crate::{msg_error, msg_print};
25use anyhow::Result;
26use chrono::{DateTime, Duration, Local, NaiveDate};
27use dialoguer::{Input, theme::ColorfulTheme};
28use reqwest::Client;
29use serde::{Deserialize, Serialize};
30use std::collections::HashSet;
31
32/// GitLab client; stateless - the token rides on every request.
33#[derive(Debug)]
34pub struct GitLab {
35    client: Client,
36    config: GitLabConfig,
37}
38
39/// Represents a GitLab user event from the events API.
40///
41/// GitLab events capture various user activities including pushes, merges,
42/// comments, and other repository interactions. This structure focuses on
43/// push events which contain commit information.
44#[derive(Debug, Deserialize)]
45struct Event {
46    /// Type of action performed (e.g., "pushed to", "opened", "commented on")
47    action_name: String,
48    /// Additional data for push events, contains commit references
49    push_data: Option<PushData>,
50    /// GitLab project ID where the event occurred
51    project_id: u32,
52}
53
54/// Push event data containing commit references.
55///
56/// When a user pushes commits to a repository, GitLab includes additional
57/// metadata about the push operation. Only `commit_to` (the tip) is enough for
58/// single-commit pushes; multi-commit pushes also expose `commit_from` and
59/// `commit_count` so the full range can be expanded via the compare API.
60#[derive(Debug, Deserialize)]
61struct PushData {
62    /// SHA of the tip commit after the push
63    commit_to: Option<String>,
64    /// SHA of the tip before the push (exclusive start of the pushed range)
65    commit_from: Option<String>,
66    /// Number of commits included in the push
67    commit_count: Option<u32>,
68}
69
70/// Response from GitLab's repository compare API.
71#[derive(Debug, Deserialize)]
72struct CompareResult {
73    commits: Vec<Commit>,
74}
75
76/// Simplified commit information for task creation.
77#[derive(Debug)]
78pub struct CommitInfo {
79    /// Full SHA hash of the commit for unique identification
80    pub sha: String,
81    /// First line of the commit message (typically the summary)
82    pub message: String,
83}
84
85/// Detailed commit object returned by GitLab's commits / compare APIs.
86#[derive(Debug, Deserialize)]
87struct Commit {
88    /// Full SHA identifier of the commit
89    id: String,
90    /// Complete commit message including body and trailers
91    message: String,
92    /// Author email (used to keep only the current user's commits)
93    author_email: Option<String>,
94    /// Author display name (fallback when email is missing)
95    author_name: Option<String>,
96    /// When the commit was authored (ISO-8601)
97    authored_date: Option<String>,
98    /// When the commit was committed (fallback date filter)
99    committed_date: Option<String>,
100}
101
102/// GitLab user information for the authenticated account.
103#[derive(Debug, Deserialize)]
104struct User {
105    /// Numeric user identifier in GitLab
106    id: u32,
107    /// Primary email from `/user` (best match for commit author_email)
108    email: Option<String>,
109    /// Display name (fallback author match)
110    name: Option<String>,
111}
112
113impl GitLab {
114    /// Builds a client from the config; no network activity yet.
115    ///
116    /// ```rust,no_run
117    /// # use kasl::api::gitlab::{GitLab, GitLabConfig};
118    /// let config = GitLabConfig {
119    ///     access_token: "glpat-xxxxxxxxxxxxxxxxxxxx".to_string(),
120    ///     api_url: "https://gitlab.example.com".to_string(),
121    /// };
122    /// let client = GitLab::new(&config);
123    /// ```
124    pub fn new(config: &GitLabConfig) -> Self {
125        Self {
126            client: Client::new(),
127            config: config.clone(),
128        }
129    }
130
131    /// The authenticated user's numeric id (`GET /user`, `read_user` scope).
132    pub async fn get_user_id(&self) -> Result<u32> {
133        Ok(self.get_current_user().await?.id)
134    }
135
136    /// Fetches the authenticated GitLab user (`/user`).
137    async fn get_current_user(&self) -> Result<User> {
138        let url = format!("{}/api/v4/user", self.config.api_url);
139        let response = self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await?;
140
141        Ok(response.json::<User>().await?)
142    }
143
144    /// Today's commits by the authenticated user, deduplicated across
145    /// pushes, first message line only - the feed for task discovery.
146    pub async fn get_today_commits(&self) -> Result<Vec<CommitInfo>> {
147        // Events window is wider (yesterday..tomorrow) to absorb timezone skew;
148        // individual commits are then filtered to local today + current author.
149        let today = Local::now();
150        let today_date = today.date_naive();
151        let yesterday = (today - Duration::days(1)).format("%Y-%m-%d").to_string();
152        let tomorrow = (today + Duration::days(1)).format("%Y-%m-%d").to_string();
153
154        let user = self.get_current_user().await.inspect_err(|e| {
155            msg_error!(Message::GitlabUserIdFailed(e.to_string()));
156        })?;
157
158        let events = self.fetch_user_events(user.id, &yesterday, &tomorrow).await?;
159
160        // Expand each push to all commits in the range (not only the tip).
161        let mut commits_info = Vec::new();
162        let mut seen_shas = HashSet::new();
163
164        for event in events {
165            if !matches!(event.action_name.as_str(), "pushed to" | "pushed new") {
166                continue;
167            }
168            let Some(push_data) = event.push_data else {
169                continue;
170            };
171
172            let commits = match self.commits_for_push(event.project_id, &push_data).await {
173                Ok(c) => c,
174                Err(_) => continue,
175            };
176
177            for commit in commits {
178                if !is_commit_by_user(&commit, &user) {
179                    continue;
180                }
181                if !is_commit_on_date(&commit, today_date) {
182                    continue;
183                }
184                if !seen_shas.insert(commit.id.clone()) {
185                    continue;
186                }
187                let clean_message = commit.message.split_once('\n').map(|(part, _)| part).unwrap_or(&commit.message).to_string();
188
189                commits_info.push(CommitInfo {
190                    sha: commit.id,
191                    message: clean_message,
192                });
193            }
194        }
195
196        Ok(commits_info)
197    }
198
199    /// Fetches all pages of user events in the given date window.
200    async fn fetch_user_events(&self, user_id: u32, after: &str, before: &str) -> Result<Vec<Event>> {
201        let mut all = Vec::new();
202        let mut page: u32 = 1;
203
204        loop {
205            let url = format!("{}/api/v4/users/{}/events", self.config.api_url, user_id);
206            let response = self
207                .client
208                .get(&url)
209                .header("PRIVATE-TOKEN", &self.config.access_token)
210                .query(&[("after", after), ("before", before), ("per_page", "100"), ("page", &page.to_string())])
211                .send()
212                .await?;
213
214            if !response.status().is_success() {
215                let status = response.status();
216                let body = response.text().await.unwrap_or_default();
217                anyhow::bail!("GitLab events request failed: HTTP {status}: {body}");
218            }
219
220            let batch: Vec<Event> = response.json().await?;
221            let batch_len = batch.len();
222            all.extend(batch);
223
224            if batch_len < 100 {
225                break;
226            }
227            page += 1;
228        }
229
230        Ok(all)
231    }
232
233    /// Returns every commit included in a push event.
234    ///
235    /// Single-commit pushes use the tip (`commit_to`). Multi-commit pushes use
236    /// GitLab's compare API between `commit_from` and `commit_to`, otherwise only
237    /// the merge/tip message would be visible to task discovery.
238    async fn commits_for_push(&self, project_id: u32, push: &PushData) -> Result<Vec<Commit>> {
239        let Some(commit_to) = push.commit_to.as_deref() else {
240            return Ok(Vec::new());
241        };
242
243        let count = push.commit_count.unwrap_or(1);
244        if count > 1
245            && let Some(commit_from) = push.commit_from.as_deref()
246            && !is_null_sha(commit_from)
247            && commit_from != commit_to
248        {
249            match self.compare_commits(project_id, commit_from, commit_to).await {
250                Ok(commits) if !commits.is_empty() => return Ok(commits),
251                _ => {}
252            }
253        }
254
255        Ok(vec![self.get_commit_detail(project_id, commit_to).await?])
256    }
257
258    /// Fetches commits in `(from, to]` via the repository compare API.
259    async fn compare_commits(&self, project_id: u32, from: &str, to: &str) -> Result<Vec<Commit>> {
260        let url = format!("{}/api/v4/projects/{}/repository/compare", self.config.api_url, project_id);
261        let response = self
262            .client
263            .get(&url)
264            .header("PRIVATE-TOKEN", &self.config.access_token)
265            .query(&[("from", from), ("to", to)])
266            .send()
267            .await?;
268
269        if !response.status().is_success() {
270            let status = response.status();
271            let body = response.text().await.unwrap_or_default();
272            anyhow::bail!("GitLab compare failed: HTTP {status}: {body}");
273        }
274
275        Ok(response.json::<CompareResult>().await?.commits)
276    }
277
278    /// Fetches one commit's full object from the commits API.
279    async fn get_commit_detail(&self, project_id: u32, commit_sha: &str) -> Result<Commit> {
280        let url = format!("{}/api/v4/projects/{}/repository/commits/{}", self.config.api_url, project_id, commit_sha);
281        let response = self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await?;
282
283        Ok(response.json::<Commit>().await?)
284    }
285}
286
287/// Returns true for GitLab's all-zero "no parent" SHA used on new branches.
288fn is_null_sha(sha: &str) -> bool {
289    !sha.is_empty() && sha.bytes().all(|b| b == b'0')
290}
291
292/// True when the commit author matches the authenticated GitLab user.
293///
294/// Prefers email (stable across display-name spelling); falls back to name.
295fn is_commit_by_user(commit: &Commit, user: &User) -> bool {
296    if let (Some(commit_email), Some(user_email)) = (&commit.author_email, &user.email)
297        && !user_email.is_empty()
298        && commit_email.eq_ignore_ascii_case(user_email)
299    {
300        return true;
301    }
302
303    if let (Some(commit_name), Some(user_name)) = (&commit.author_name, &user.name)
304        && !user_name.is_empty()
305        && commit_name.eq_ignore_ascii_case(user_name)
306    {
307        return true;
308    }
309
310    false
311}
312
313/// True when the commit was authored on `date` (local TZ).
314///
315/// Falls back to `committed_date` only when `authored_date` is missing.
316fn is_commit_on_date(commit: &Commit, date: NaiveDate) -> bool {
317    let raw = commit.authored_date.as_deref().or(commit.committed_date.as_deref());
318    let Some(raw) = raw else {
319        return false;
320    };
321    DateTime::parse_from_rfc3339(raw)
322        .map(|dt| dt.with_timezone(&Local).date_naive() == date)
323        .unwrap_or(false)
324}
325
326/// GitLab connection settings. The token IS stored in the config file -
327/// keep its scopes minimal (`read_user`, `read_repository`).
328#[derive(Serialize, Deserialize, Clone, Debug)]
329pub struct GitLabConfig {
330    /// Personal Access Token with `read_user` + `read_repository` scopes.
331    pub access_token: String,
332
333    /// Instance root URL, without the `/api/v4` path.
334    pub api_url: String,
335}
336
337impl GitLabConfig {
338    /// Module metadata for the setup wizard.
339    pub fn module() -> ConfigModule {
340        ConfigModule {
341            key: "gitlab".to_string(),
342            name: "GitLab".to_string(),
343        }
344    }
345
346    /// Interactive setup; existing values become the prompt defaults.
347    ///
348    /// ```rust,no_run
349    /// # use kasl::api::gitlab::GitLabConfig;
350    /// # fn f() -> anyhow::Result<()> {
351    /// let existing_config = Some(GitLabConfig {
352    ///     access_token: "glpat-old-token".to_string(),
353    ///     api_url: "https://gitlab.com".to_string(),
354    /// });
355    ///
356    /// let new_config = GitLabConfig::init(&existing_config)?;
357    /// # Ok(())
358    /// # }
359    /// ```
360    pub fn init(config: &Option<GitLabConfig>) -> Result<Self> {
361        // Use existing configuration as defaults, or create empty defaults
362        let config = config.clone().unwrap_or(Self {
363            access_token: "".to_string(),
364            api_url: "".to_string(),
365        });
366
367        // Display configuration module header
368        msg_print!(Message::ConfigModuleGitLab);
369
370        // Interactive configuration with existing values as defaults
371        Ok(Self {
372            access_token: Input::with_theme(&ColorfulTheme::default())
373                .with_prompt("Enter your GitLab private token")
374                .default(config.access_token)
375                .interact_text()?,
376            api_url: Input::with_theme(&ColorfulTheme::default())
377                .with_prompt("Enter the GitLab API URL")
378                .default(config.api_url)
379                .interact_text()?,
380        })
381    }
382}