Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub async fn initialize_app(
&config,
);
app.dispatch(GlimEvent::ProjectsFetch);
if config == GlimConfig::default() {
if config.gitlab_url.is_empty() || config.gitlab_token.is_empty() {
app.dispatch(GlimEvent::ConfigOpen);
}

Expand Down
80 changes: 78 additions & 2 deletions src/client/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ use super::{
error::{ClientError, Result},
};
use crate::{
domain::{JobDto, PipelineDto, ProjectDto},
id::{JobId, PipelineId, ProjectId},
domain::{JobDto, MrDto, NoteDto, PipelineDto, ProjectDto},
id::{JobId, MrIid, PipelineId, ProjectId},
};

/// Pure HTTP client for GitLab API
Expand Down Expand Up @@ -124,6 +124,78 @@ impl GitlabApi {
Ok(body.into())
}

/// Get merge request associated with a commit SHA
pub async fn get_mr_for_commit(
&self,
project_id: ProjectId,
sha: &str,
) -> Result<Option<MrDto>> {
let url = {
let config = self.config.read().unwrap();
format_compact!(
"{}/projects/{}/repository/commits/{}/merge_requests",
config.base_url,
project_id,
sha
)
};
let mrs: Vec<MrDto> = self.get_json(&url).await?;
Ok(mrs.into_iter().find(|mr| mr.state == "opened"))
}

/// Get notes (comments) for a merge request
pub async fn get_mr_notes(
&self,
project_id: ProjectId,
mr_iid: MrIid,
) -> Result<Vec<NoteDto>> {
let url = {
let config = self.config.read().unwrap();
format_compact!(
"{}/projects/{}/merge_requests/{}/notes?per_page=100&sort=asc",
config.base_url,
project_id,
mr_iid
)
};
self.get_json(&url).await
}

/// Post a note (comment) on a merge request
pub async fn post_mr_note(
&self,
project_id: ProjectId,
mr_iid: MrIid,
body: &str,
) -> Result<NoteDto> {
let url = {
let config = self.config.read().unwrap();
format_compact!(
"{}/projects/{}/merge_requests/{}/notes",
config.base_url,
project_id,
mr_iid
)
};
let payload = serde_json::json!({ "body": body });
let payload_str = serde_json::to_string(&payload)
.map_err(|e| ClientError::json_parse(url.as_str(), "Failed to serialize payload", e))?;
let (client_clone, private_token) = {
let client = self.client.read().unwrap();
let private_token = self.config.read().unwrap().private_token.clone();
(client.clone(), private_token)
};
let response = client_clone
.post(url.as_str())
.header("PRIVATE-TOKEN", private_token.as_str())
.header("Content-Type", "application/json")
.body(payload_str)
.send()
.await
.map_err(ClientError::Http)?;
self.handle_response(response).await
}

/// Update configuration
pub fn update_config(&self, config: ClientConfig) -> Result<()> {
config.validate()?;
Expand Down Expand Up @@ -285,6 +357,10 @@ impl GitlabApi {
url.push_str("&membership=true");
}

if let Some(level) = query.min_access_level {
url.push_str(&format_compact!("&min_access_level={}", level));
}

url.push_str(&format_compact!("&per_page={}", query.per_page));

url
Expand Down
59 changes: 55 additions & 4 deletions src/client/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use std::{path::PathBuf, time::Duration};

use chrono::{DateTime, Utc};
use compact_str::CompactString;
use compact_str::{format_compact, CompactString};

use super::error::{ClientError, Result};
use crate::glim_app::GlimConfig;
Expand Down Expand Up @@ -72,6 +72,8 @@ pub struct ProjectQuery {
pub archived: bool,
/// Only include projects where user is a member
pub membership: bool,
/// Minimum access level for the authenticated user (10=Guest, 20=Reporter, 30=Developer, 40=Maintainer, 50=Owner)
pub min_access_level: Option<u32>,
/// Search in namespaces
pub search_namespaces: bool,
}
Expand Down Expand Up @@ -225,7 +227,8 @@ impl ClientConfig {
per_page: self.request.per_page,
include_statistics: true,
archived: false,
membership: true,
membership: false,
min_access_level: Some(10),
search_namespaces: true,
..Default::default()
}
Expand All @@ -242,7 +245,13 @@ impl ClientConfig {

impl From<GlimConfig> for ClientConfig {
fn from(config: GlimConfig) -> Self {
Self::new(config.gitlab_url, config.gitlab_token).with_search_filter(config.search_filter)
let url = config.gitlab_url.trim_end_matches('/');
let base_url: CompactString = if url.ends_with("/api/v4") {
url.into()
} else {
format_compact!("{}/api/v4", url)
};
Self::new(base_url, config.gitlab_token).with_search_filter(config.search_filter)
}
}

Expand Down Expand Up @@ -360,11 +369,53 @@ mod tests {
};

let client_config = ClientConfig::from(glim_config);
assert_eq!(client_config.base_url, "https://gitlab.example.com");
assert_eq!(client_config.base_url, "https://gitlab.example.com/api/v4");
assert_eq!(client_config.private_token, "test-token");
assert_eq!(client_config.search_filter, Some("test".into()));
}

#[test]
fn test_from_glim_config_url_normalization() {
// Bare URL without /api/v4 should be normalized
let config = GlimConfig {
gitlab_url: "https://gitlab.example.com".into(),
gitlab_token: "test-token".into(),
search_filter: None,
log_level: None,
animations: false,
};
assert_eq!(
ClientConfig::from(config).base_url,
"https://gitlab.example.com/api/v4"
);

// URL already ending with /api/v4 should not be doubled
let config = GlimConfig {
gitlab_url: "https://gitlab.example.com/api/v4".into(),
gitlab_token: "test-token".into(),
search_filter: None,
log_level: None,
animations: false,
};
assert_eq!(
ClientConfig::from(config).base_url,
"https://gitlab.example.com/api/v4"
);

// Trailing slash should be stripped before normalization
let config = GlimConfig {
gitlab_url: "https://gitlab.example.com/".into(),
gitlab_token: "test-token".into(),
search_filter: None,
log_level: None,
animations: false,
};
assert_eq!(
ClientConfig::from(config).base_url,
"https://gitlab.example.com/api/v4"
);
}

#[test]
fn test_default_queries() {
let config = ClientConfig::new("https://gitlab.com", "token")
Expand Down
63 changes: 62 additions & 1 deletion src/client/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ use super::{
config::ClientConfig,
error::{ClientError, Result},
};
use compact_str::CompactString;

use crate::{
dispatcher::Dispatcher,
domain::MrView,
event::{GlimEvent, IntoGlimEvent},
id::{JobId, PipelineId, ProjectId},
id::{JobId, MrIid, PipelineId, ProjectId},
result::GlimError::{self, GeneralError},
};

Expand Down Expand Up @@ -264,6 +267,64 @@ impl GitlabService {
}
});
}

/// Spawn an async task to fetch the MR associated with a commit SHA
pub fn spawn_fetch_mr(&self, project_id: ProjectId, sha: CompactString) {
let api = self.api.clone();
let sender = self.sender.clone();
self.handle.spawn(async move {
match api.get_mr_for_commit(project_id, &sha).await {
Ok(Some(mr)) => {
let mr_view = MrView::from_dto(mr, project_id);
sender.dispatch(GlimEvent::MrLoaded(project_id, Box::new(mr_view)));
},
Ok(None) => {
// No open MR found - silently ignore
},
Err(e) => {
error!(error = %e, "Failed to fetch MR for commit");
let glim_error = crate::result::GlimError::from(&e);
sender.dispatch(GlimEvent::AppError(glim_error));
},
}
});
}

/// Spawn an async task to fetch notes for a merge request
pub fn spawn_fetch_mr_notes(&self, project_id: ProjectId, mr_iid: MrIid) {
let api = self.api.clone();
let sender = self.sender.clone();
self.handle.spawn(async move {
match api.get_mr_notes(project_id, mr_iid).await {
Ok(notes) => {
sender.dispatch(GlimEvent::MrNotesLoaded(project_id, mr_iid, notes));
},
Err(e) => {
error!(error = %e, "Failed to fetch MR notes");
let glim_error = crate::result::GlimError::from(&e);
sender.dispatch(GlimEvent::AppError(glim_error));
},
}
});
}

/// Spawn an async task to post a note on a merge request
pub fn spawn_post_mr_note(&self, project_id: ProjectId, mr_iid: MrIid, body: CompactString) {
let api = self.api.clone();
let sender = self.sender.clone();
self.handle.spawn(async move {
match api.post_mr_note(project_id, mr_iid, &body).await {
Ok(_) => {
sender.dispatch(GlimEvent::MrNotePosted(project_id, mr_iid));
},
Err(e) => {
error!(error = %e, "Failed to post MR note");
let glim_error = crate::result::GlimError::from(&e);
sender.dispatch(GlimEvent::AppError(glim_error));
},
}
});
}
}

// Convert ClientError to the application's GlimError type
Expand Down
7 changes: 4 additions & 3 deletions src/client/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub fn sample_project_dto() -> ProjectDto {
id: ProjectId::new(123),
path_with_namespace: "group/project".into(),
description: Some("Test project".into()),
default_branch: "main".into(),
default_branch: Some("main".into()),
ssh_url_to_repo: "git@gitlab.example.com:group/project.git".into(),
web_url: "https://gitlab.example.com/group/project".into(),
last_activity_at: DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z")
Expand All @@ -65,6 +65,7 @@ pub fn sample_pipeline_dto() -> PipelineDto {
status: crate::domain::PipelineStatus::Success,
source: crate::domain::PipelineSource::Push,
branch: "main".into(),
sha: "abc123def456".into(),
web_url: "https://gitlab.example.com/group/project/-/pipelines/456".into(),
created_at: DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z")
.unwrap()
Expand All @@ -81,10 +82,10 @@ pub fn sample_job_dto() -> JobDto {
id: JobId::new(789),
name: "test-job".into(),
stage: "test".into(),
commit: CommitDto {
commit: Some(CommitDto {
title: "Test commit".into(),
author_name: "Test Author".into(),
},
}),
status: crate::domain::PipelineStatus::Success,
created_at: DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z")
.unwrap()
Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{

pub fn default_config_path() -> PathBuf {
if let Some(dirs) = BaseDirs::new() {
dirs.config_dir().join("glim.toml")
dirs.home_dir().join(".config").join("glim").join("glim.toml")
} else {
PathBuf::from("glim.toml")
}
Expand Down
Loading