1use 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#[derive(Debug)]
34pub struct GitLab {
35 client: Client,
36 config: GitLabConfig,
37}
38
39#[derive(Debug, Deserialize)]
45struct Event {
46 action_name: String,
48 push_data: Option<PushData>,
50 project_id: u32,
52}
53
54#[derive(Debug, Deserialize)]
61struct PushData {
62 commit_to: Option<String>,
64 commit_from: Option<String>,
66 commit_count: Option<u32>,
68}
69
70#[derive(Debug, Deserialize)]
72struct CompareResult {
73 commits: Vec<Commit>,
74}
75
76#[derive(Debug)]
78pub struct CommitInfo {
79 pub sha: String,
81 pub message: String,
83}
84
85#[derive(Debug, Deserialize)]
87struct Commit {
88 id: String,
90 message: String,
92 author_email: Option<String>,
94 author_name: Option<String>,
96 authored_date: Option<String>,
98 committed_date: Option<String>,
100}
101
102#[derive(Debug, Deserialize)]
104struct User {
105 id: u32,
107 email: Option<String>,
109 name: Option<String>,
111}
112
113impl GitLab {
114 pub fn new(config: &GitLabConfig) -> Self {
125 Self {
126 client: Client::new(),
127 config: config.clone(),
128 }
129 }
130
131 pub async fn get_user_id(&self) -> Result<u32> {
133 Ok(self.get_current_user().await?.id)
134 }
135
136 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 pub async fn get_today_commits(&self) -> Result<Vec<CommitInfo>> {
147 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 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 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 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 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 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
287fn is_null_sha(sha: &str) -> bool {
289 !sha.is_empty() && sha.bytes().all(|b| b == b'0')
290}
291
292fn 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
313fn 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#[derive(Serialize, Deserialize, Clone, Debug)]
329pub struct GitLabConfig {
330 pub access_token: String,
332
333 pub api_url: String,
335}
336
337impl GitLabConfig {
338 pub fn module() -> ConfigModule {
340 ConfigModule {
341 key: "gitlab".to_string(),
342 name: "GitLab".to_string(),
343 }
344 }
345
346 pub fn init(config: &Option<GitLabConfig>) -> Result<Self> {
361 let config = config.clone().unwrap_or(Self {
363 access_token: "".to_string(),
364 api_url: "".to_string(),
365 });
366
367 msg_print!(Message::ConfigModuleGitLab);
369
370 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}