use crate::libs::config::ConfigModule;
use crate::libs::messages::Message;
use crate::{msg_error, msg_print};
use anyhow::Result;
use chrono::{DateTime, Duration, Local, NaiveDate};
use dialoguer::{Input, theme::ColorfulTheme};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug)]
pub struct GitLab {
client: Client,
config: GitLabConfig,
}
#[derive(Debug, Deserialize)]
struct Event {
action_name: String,
push_data: Option<PushData>,
project_id: u32,
}
#[derive(Debug, Deserialize)]
struct PushData {
commit_to: Option<String>,
commit_from: Option<String>,
commit_count: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct CompareResult {
commits: Vec<Commit>,
}
#[derive(Debug)]
pub struct CommitInfo {
pub sha: String,
pub message: String,
}
#[derive(Debug, Deserialize)]
struct Commit {
id: String,
message: String,
author_email: Option<String>,
author_name: Option<String>,
authored_date: Option<String>,
committed_date: Option<String>,
}
#[derive(Debug, Deserialize)]
struct User {
id: u32,
email: Option<String>,
name: Option<String>,
}
impl GitLab {
pub fn new(config: &GitLabConfig) -> Self {
Self {
client: Client::new(),
config: config.clone(),
}
}
pub async fn get_user_id(&self) -> Result<u32> {
Ok(self.get_current_user().await?.id)
}
async fn get_current_user(&self) -> Result<User> {
let url = format!("{}/api/v4/user", self.config.api_url);
let response = self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await?;
Ok(response.json::<User>().await?)
}
pub async fn get_today_commits(&self) -> Result<Vec<CommitInfo>> {
let today = Local::now();
let today_date = today.date_naive();
let yesterday = (today - Duration::days(1)).format("%Y-%m-%d").to_string();
let tomorrow = (today + Duration::days(1)).format("%Y-%m-%d").to_string();
let user = self.get_current_user().await.inspect_err(|e| {
msg_error!(Message::GitlabUserIdFailed(e.to_string()));
})?;
let events = self.fetch_user_events(user.id, &yesterday, &tomorrow).await?;
let mut commits_info = Vec::new();
let mut seen_shas = HashSet::new();
for event in events {
if !matches!(event.action_name.as_str(), "pushed to" | "pushed new") {
continue;
}
let Some(push_data) = event.push_data else {
continue;
};
let commits = match self.commits_for_push(event.project_id, &push_data).await {
Ok(c) => c,
Err(_) => continue,
};
for commit in commits {
if !is_commit_by_user(&commit, &user) {
continue;
}
if !is_commit_on_date(&commit, today_date) {
continue;
}
if !seen_shas.insert(commit.id.clone()) {
continue;
}
let clean_message = commit.message.split_once('\n').map(|(part, _)| part).unwrap_or(&commit.message).to_string();
commits_info.push(CommitInfo {
sha: commit.id,
message: clean_message,
});
}
}
Ok(commits_info)
}
async fn fetch_user_events(&self, user_id: u32, after: &str, before: &str) -> Result<Vec<Event>> {
let mut all = Vec::new();
let mut page: u32 = 1;
loop {
let url = format!("{}/api/v4/users/{}/events", self.config.api_url, user_id);
let response = self
.client
.get(&url)
.header("PRIVATE-TOKEN", &self.config.access_token)
.query(&[("after", after), ("before", before), ("per_page", "100"), ("page", &page.to_string())])
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("GitLab events request failed: HTTP {status}: {body}");
}
let batch: Vec<Event> = response.json().await?;
let batch_len = batch.len();
all.extend(batch);
if batch_len < 100 {
break;
}
page += 1;
}
Ok(all)
}
async fn commits_for_push(&self, project_id: u32, push: &PushData) -> Result<Vec<Commit>> {
let Some(commit_to) = push.commit_to.as_deref() else {
return Ok(Vec::new());
};
let count = push.commit_count.unwrap_or(1);
if count > 1
&& let Some(commit_from) = push.commit_from.as_deref()
&& !is_null_sha(commit_from)
&& commit_from != commit_to
{
match self.compare_commits(project_id, commit_from, commit_to).await {
Ok(commits) if !commits.is_empty() => return Ok(commits),
_ => {}
}
}
Ok(vec![self.get_commit_detail(project_id, commit_to).await?])
}
async fn compare_commits(&self, project_id: u32, from: &str, to: &str) -> Result<Vec<Commit>> {
let url = format!("{}/api/v4/projects/{}/repository/compare", self.config.api_url, project_id);
let response = self
.client
.get(&url)
.header("PRIVATE-TOKEN", &self.config.access_token)
.query(&[("from", from), ("to", to)])
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("GitLab compare failed: HTTP {status}: {body}");
}
Ok(response.json::<CompareResult>().await?.commits)
}
async fn get_commit_detail(&self, project_id: u32, commit_sha: &str) -> Result<Commit> {
let url = format!("{}/api/v4/projects/{}/repository/commits/{}", self.config.api_url, project_id, commit_sha);
let response = self.client.get(&url).header("PRIVATE-TOKEN", &self.config.access_token).send().await?;
Ok(response.json::<Commit>().await?)
}
}
fn is_null_sha(sha: &str) -> bool {
!sha.is_empty() && sha.bytes().all(|b| b == b'0')
}
fn is_commit_by_user(commit: &Commit, user: &User) -> bool {
if let (Some(commit_email), Some(user_email)) = (&commit.author_email, &user.email)
&& !user_email.is_empty()
&& commit_email.eq_ignore_ascii_case(user_email)
{
return true;
}
if let (Some(commit_name), Some(user_name)) = (&commit.author_name, &user.name)
&& !user_name.is_empty()
&& commit_name.eq_ignore_ascii_case(user_name)
{
return true;
}
false
}
fn is_commit_on_date(commit: &Commit, date: NaiveDate) -> bool {
let raw = commit.authored_date.as_deref().or(commit.committed_date.as_deref());
let Some(raw) = raw else {
return false;
};
DateTime::parse_from_rfc3339(raw)
.map(|dt| dt.with_timezone(&Local).date_naive() == date)
.unwrap_or(false)
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GitLabConfig {
pub access_token: String,
pub api_url: String,
}
impl GitLabConfig {
pub fn module() -> ConfigModule {
ConfigModule {
key: "gitlab".to_string(),
name: "GitLab".to_string(),
}
}
pub fn init(config: &Option<GitLabConfig>) -> Result<Self> {
let config = config.clone().unwrap_or(Self {
access_token: "".to_string(),
api_url: "".to_string(),
});
msg_print!(Message::ConfigModuleGitLab);
Ok(Self {
access_token: Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter your GitLab private token")
.default(config.access_token)
.interact_text()?,
api_url: Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter the GitLab API URL")
.default(config.api_url)
.interact_text()?,
})
}
}