use crate::models::{
AppEvent, GitLabMilestone, GitLabMr, GitLabRef, MergeabilityStatus, MrLoadedData, Pipeline,
PipelineJob,
};
use crate::utils::{calculate_relevance, RELEVANCE_THRESHOLD};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Semaphore;
pub const MAX_CONCURRENT_REQUESTS: usize = 3;
#[derive(Clone)]
pub struct FetchContext {
pub base_url: String,
pub token: String,
pub project_id: String,
pub branches: Vec<String>,
}
#[derive(Clone, Default)]
pub struct CachedMrData {
pub title: Option<String>,
pub sha: Option<String>,
pub description: Option<String>,
pub author: Option<String>,
pub assignee: Option<String>,
pub web_url: Option<String>,
pub labels: Option<Vec<String>>,
pub updated_at: Option<String>,
pub pipelines: Vec<Pipeline>,
}
async fn fetch_pipelines(ctx: &FetchContext, mr_id: &str) -> Vec<Pipeline> {
let client = reqwest::Client::new();
let pipelines_url = format!(
"{}/api/v4/projects/{}/merge_requests/{}/pipelines?per_page=5",
ctx.base_url, ctx.project_id, mr_id
);
let res = match client
.get(&pipelines_url)
.header("PRIVATE-TOKEN", &ctx.token)
.send()
.await
{
Ok(r) if r.status().is_success() => r,
_ => return vec![],
};
let mut pipelines: Vec<Pipeline> = match res.json().await {
Ok(p) => p,
Err(_) => return vec![],
};
let jobs_futures: Vec<_> = pipelines
.iter()
.map(|p| {
let jobs_url = format!(
"{}/api/v4/projects/{}/pipelines/{}/jobs?per_page=50",
ctx.base_url, ctx.project_id, p.id
);
let client = client.clone();
let token = ctx.token.clone();
async move {
let res = client
.get(&jobs_url)
.header("PRIVATE-TOKEN", &token)
.send()
.await
.ok()?;
if res.status().is_success() {
match res.json::<Vec<PipelineJob>>().await {
Ok(jobs) => Some(jobs),
Err(e) => {
tracing::warn!("Failed to deserialize jobs: {e}");
None
}
}
} else {
tracing::warn!("Jobs endpoint returned non-2xx: {}", res.status());
None
}
}
})
.collect();
let jobs_results = futures::future::join_all(jobs_futures).await;
for (pipeline, jobs) in pipelines.iter_mut().zip(jobs_results) {
pipeline.jobs = jobs.unwrap_or_default();
}
pipelines
}
pub async fn fetch_milestones(ctx: &FetchContext) -> Vec<GitLabMilestone> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/v4/projects/{}/milestones?state=active&per_page=100",
ctx.base_url, ctx.project_id
);
let res = match client
.get(&url)
.header("PRIVATE-TOKEN", &ctx.token)
.send()
.await
{
Ok(r) if r.status().is_success() => r,
_ => return vec![],
};
let mut milestones: Vec<GitLabMilestone> = match res.json().await {
Ok(m) => m,
Err(_) => return vec![],
};
milestones.sort_by(|a, b| a.title.cmp(&b.title));
milestones
}
pub fn spawn_milestones_fetch(ctx: FetchContext, tx: tokio::sync::mpsc::UnboundedSender<AppEvent>) {
tokio::spawn(async move {
let milestones = fetch_milestones(&ctx).await;
let _ = tx.send(AppEvent::MilestonesLoaded(milestones));
});
}
pub async fn fetch_milestone_mr_ids(ctx: &FetchContext, milestone_title: &str) -> Vec<String> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/v4/projects/{}/merge_requests?milestone={}&per_page=100",
ctx.base_url,
ctx.project_id,
urlencoding::encode(milestone_title)
);
let res = match client
.get(&url)
.header("PRIVATE-TOKEN", &ctx.token)
.send()
.await
{
Ok(r) if r.status().is_success() => r,
_ => return vec![],
};
let mrs: Vec<serde_json::Value> = match res.json().await {
Ok(v) => v,
Err(_) => return vec![],
};
mrs.iter()
.filter_map(|v| v.get("iid").and_then(|id| id.as_u64()))
.map(|id| id.to_string())
.collect()
}
pub fn spawn_milestone_mrs_fetch(
ctx: FetchContext,
milestone_title: String,
tx: tokio::sync::mpsc::UnboundedSender<AppEvent>,
) {
tokio::spawn(async move {
let mr_ids = fetch_milestone_mr_ids(&ctx, &milestone_title).await;
let _ = tx.send(AppEvent::MilestoneMrsLoaded {
milestone_title,
mr_ids,
});
});
}
pub fn spawn_mr_fetch(
ctx: FetchContext,
mr_id: String,
cached: CachedMrData,
semaphore: Arc<Semaphore>,
tx: tokio::sync::mpsc::UnboundedSender<AppEvent>,
) {
tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
match fetch_gitlab_data(&ctx, &mr_id, cached).await {
Ok(data) => {
let _ = tx.send(AppEvent::MrLoaded(Box::new(data)));
}
Err(err_msg) => {
let _ = tx.send(AppEvent::MrFailed {
id: mr_id,
error: err_msg,
});
}
}
});
}
pub async fn fetch_gitlab_data(
ctx: &FetchContext,
mr_id: &str,
cached: CachedMrData,
) -> Result<MrLoadedData, String> {
let client = reqwest::Client::new();
let mr_url = format!(
"{}/api/v4/projects/{}/merge_requests/{}",
ctx.base_url, ctx.project_id, mr_id
);
let mr_res = client
.get(&mr_url)
.header("PRIVATE-TOKEN", &ctx.token)
.send()
.await
.map_err(|e| format!("MR network error: {}", e))?;
if !mr_res.status().is_success() {
return Err(format!("HTTP {} on MR", mr_res.status()));
}
let mr: GitLabMr = mr_res
.json()
.await
.map_err(|e| format!("Error reading MR JSON: {}", e))?;
let updated_at = mr.updated_at.clone();
let state = mr.state.clone().unwrap_or_default();
let user_notes_count = mr.user_notes_count.unwrap_or(0);
let mergeability = if mr.has_conflicts == Some(true) {
MergeabilityStatus::Conflict
} else {
match mr.detailed_merge_status.as_deref() {
Some("mergeable") => MergeabilityStatus::Mergeable,
Some("need_rebase") | Some("behind_target_branch") => MergeabilityStatus::NeedsRebase,
Some("merge_conflict") | Some("conflict") => MergeabilityStatus::Conflict,
Some("broken_status") => MergeabilityStatus::Conflict,
Some("security_policy_violations") => MergeabilityStatus::Conflict,
Some("not_open") => MergeabilityStatus::NotOpen,
Some("draft_status") => MergeabilityStatus::Draft,
Some("discussions_not_resolved") => MergeabilityStatus::DiscussionsNotResolved,
Some("ci_must_pass") => MergeabilityStatus::CiMustPass,
Some("ci_still_running") => MergeabilityStatus::CiStillRunning,
Some("not_approved") | Some("approvals_syncing") => MergeabilityStatus::NotApproved,
Some("requested_changes") => MergeabilityStatus::RequestedChanges,
Some("checking") | Some("unchecked") | Some("preparing") => MergeabilityStatus::Unknown,
Some("external_status_checks") => MergeabilityStatus::Unknown,
Some("jira_association_missing") => MergeabilityStatus::Unknown,
Some("commits_status") => MergeabilityStatus::Unknown,
_ => match mr.merge_status.as_deref() {
Some("can_be_merged") => MergeabilityStatus::Mergeable,
Some("cannot_be_merged") => MergeabilityStatus::Conflict,
_ => MergeabilityStatus::Unknown,
},
}
};
let milestone_due_date = mr.milestone.as_ref().and_then(|m| m.due_date.clone());
let milestone = mr
.milestone
.map(|m| m.title)
.unwrap_or_else(|| "None".to_string());
let reviewers = mr
.reviewers
.unwrap_or_default()
.into_iter()
.map(|u| format!("{} (@{})", u.name, u.username))
.collect::<Vec<_>>();
let merged_by = mr
.merged_by
.map(|u| format!("{} (@{})", u.name, u.username));
let merged_at = mr.merged_at.clone();
let source_branch = mr
.source_branch
.clone()
.unwrap_or_else(|| "unknown".to_string());
let (title, sha, description, author, assignee, web_url, labels) = match (
cached.title,
cached.sha,
cached.description,
cached.author,
cached.assignee,
cached.web_url,
cached.labels,
) {
(Some(t), Some(s), Some(d), Some(a), Some(asg), Some(w), Some(lbls))
if !t.contains("⚠️ ERROR") && !w.is_empty() =>
{
(t, Some(s), d, a, asg, w, lbls)
}
_ => {
let sha = mr.merge_commit_sha.or(mr.squash_commit_sha);
let title = mr.title;
let desc = mr.description.unwrap_or_default();
let auth = mr
.author
.map(|u| format!("{} (@{})", u.name, u.username))
.unwrap_or_else(|| "unknown".to_string());
let asg = mr
.assignee
.map(|u| format!("{} (@{})", u.name, u.username))
.unwrap_or_else(|| "none".to_string());
let web_url = mr.web_url.unwrap_or_default();
let labels = mr.labels.unwrap_or_default();
(title, sha, desc, auth, asg, web_url, labels)
}
};
let mut found_branches = HashSet::new();
if let Some(ref commit_sha) = sha {
let refs_url_standard = format!(
"{}/api/v4/projects/{}/repository/commits/{}/refs?type=branch",
ctx.base_url, ctx.project_id, commit_sha
);
if let Ok(refs_res) = client
.get(&refs_url_standard)
.header("PRIVATE-TOKEN", &ctx.token)
.send()
.await
{
if refs_res.status().is_success() {
if let Ok(refs) = refs_res.json::<Vec<GitLabRef>>().await {
for r in refs {
let cleaned = r.name.replace("refs/heads/", "");
if ctx.branches.contains(&cleaned) {
found_branches.insert(cleaned);
}
}
}
}
}
}
let gitlab_search_query = title
.split(|c: char| !c.is_alphabetic())
.filter(|w| {
w.len() > 3
&& !["feat", "fix", "refactor", "chore", "open"]
.contains(&w.to_lowercase().as_str())
})
.take(3)
.collect::<Vec<&str>>()
.join(" ");
for branch in &ctx.branches {
if found_branches.contains(branch) {
continue;
}
let commits_url = format!(
"{}/api/v4/projects/{}/repository/commits?ref_name={}&search={}&per_page=15",
ctx.base_url,
ctx.project_id,
branch,
urlencoding::encode(&gitlab_search_query)
);
if let Ok(commits_res) = client
.get(&commits_url)
.header("PRIVATE-TOKEN", &ctx.token)
.send()
.await
{
if commits_res.status().is_success() {
if let Ok(commits) = commits_res.json::<Vec<serde_json::Value>>().await {
for commit in commits {
if let Some(commit_message) = commit.get("message").and_then(|v| v.as_str())
{
let score = calculate_relevance(&title, commit_message);
if score >= RELEVANCE_THRESHOLD {
found_branches.insert(branch.clone());
break;
}
}
}
}
}
}
}
let cached_has_jobs = cached.pipelines.iter().any(|p| !p.jobs.is_empty());
let cached_has_dates = cached.pipelines.iter().all(|p| p.created_at.is_some());
let pipelines = if updated_at.is_some()
&& updated_at == cached.updated_at
&& !cached.pipelines.is_empty()
&& cached_has_jobs
&& cached_has_dates
{
cached.pipelines
} else {
fetch_pipelines(ctx, mr_id).await
};
let target_branch = mr.target_branch.unwrap_or_else(|| "unknown".to_string());
Ok(MrLoadedData {
id: mr_id.to_string(),
title,
sha,
branches: found_branches,
description,
author,
assignee,
reviewers,
milestone,
milestone_due_date,
web_url,
labels,
updated_at,
source_branch,
target_branch,
state,
merged_by,
merged_at,
mergeability,
pipelines,
user_notes_count,
})
}