From 2f306887116e83181d2ea9b337f6227edd5fc2eb Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 11:49:06 -0400 Subject: [PATCH 01/44] feat(provider): implement provider connection management and UI enhancements - Added new functions for listing, saving, and validating provider connections, supporting both GitLab and YouTrack. - Introduced the YouTrackAuthPanel component for managing YouTrack connections, enhancing the setup and settings pages. - Updated existing components to utilize provider-neutral logic, improving maintainability and user experience. - Enhanced the IssueHubPage and related components to display provider-specific information, including badges for origin visibility. - Refactored tests to cover new functionalities and ensure compatibility with the updated provider management features. --- src-tauri/src/commands/auth.rs | 43 +++- src-tauri/src/commands/dashboard.rs | 25 +++ src-tauri/src/db/connection.rs | 200 +++++++++++++++++- src-tauri/src/domain/models.rs | 11 + src-tauri/src/lib.rs | 14 +- src-tauri/src/providers/mod.rs | 1 + src-tauri/src/providers/youtrack.rs | 71 +++++++ src-tauri/src/services/auth.rs | 54 +++++ src-tauri/src/services/issues.rs | 87 +++++++- src-tauri/src/services/shared.rs | 11 +- src-tauri/src/services/sync.rs | 43 ++++ src/app/desktop/TauriService/tauri.ts | 31 +++ src/app/root/App/App.test.tsx | 4 +- src/app/root/App/App.tsx | 28 ++- src/app/routes/SetupRoutes/SetupRoutes.tsx | 28 ++- src/app/state/AppStore/app-store.test.ts | 12 +- src/app/state/AppStore/app-store.ts | 6 +- .../AppStore/internal/app-store-actions.ts | 8 +- .../YouTrackAuthPanel.test.tsx | 57 +++++ .../YouTrackAuthPanel/YouTrackAuthPanel.tsx | 93 ++++++++ .../screens/IssueHubPage/IssueHubPage.tsx | 15 +- .../IssueDetailsMainSection.tsx | 6 +- .../AssignedIssueListRow.tsx | 2 + .../IssueOriginBadge.test.tsx | 12 ++ .../ui/IssueOriginBadge/IssueOriginBadge.tsx | 26 +++ .../OnboardingFlow/OnboardingFlow.test.tsx | 2 +- .../screens/SettingsPage/SettingsPage.tsx | 11 +- .../SettingsConnectionSection.tsx | 53 +++-- .../SetupProviderPage/SetupProviderPage.tsx | 52 +++-- src/shared/types/dashboard.ts | 11 + 30 files changed, 929 insertions(+), 88 deletions(-) create mode 100644 src-tauri/src/providers/youtrack.rs create mode 100644 src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx create mode 100644 src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx create mode 100644 src/features/issues/ui/IssueOriginBadge/IssueOriginBadge.test.tsx create mode 100644 src/features/issues/ui/IssueOriginBadge/IssueOriginBadge.tsx diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 8567cb45..c1fd6a21 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -5,7 +5,7 @@ use tauri::{AppHandle, Manager, State, WebviewUrl, WebviewWindowBuilder}; use crate::{ domain::models::{ AuthLaunchPlan, GitLabConnectionInput, GitLabUserInfo, OAuthCallbackPayload, - OAuthCallbackResolution, ProviderConnection, + OAuthCallbackResolution, ProviderConnection, ProviderConnectionInput, }, error::AppError, services::{auth, shared}, @@ -27,6 +27,13 @@ pub fn list_gitlab_connections( result } +#[tauri::command] +pub fn list_provider_connections( + state: State<'_, AppState>, +) -> Result, AppError> { + auth::load_provider_connections(&state) +} + #[tauri::command] pub fn save_gitlab_connection( state: State<'_, AppState>, @@ -35,6 +42,14 @@ pub fn save_gitlab_connection( auth::save_gitlab_connection(&state, input) } +#[tauri::command] +pub fn save_provider_connection( + state: State<'_, AppState>, + input: ProviderConnectionInput, +) -> Result { + auth::save_provider_connection(&state, input) +} + #[tauri::command] pub fn save_gitlab_pat( state: State<'_, AppState>, @@ -44,6 +59,16 @@ pub fn save_gitlab_pat( auth::save_gitlab_pat(&state, &host, &token) } +#[tauri::command] +pub fn save_provider_pat( + state: State<'_, AppState>, + provider: String, + host: String, + token: String, +) -> Result { + auth::save_provider_pat(&state, &provider, &host, &token) +} + #[tauri::command] pub fn begin_gitlab_oauth( app: AppHandle, @@ -92,3 +117,19 @@ pub async fn validate_gitlab_token( ) .await } + +#[tauri::command] +pub async fn validate_provider_token( + state: State<'_, AppState>, + provider: String, + host: String, +) -> Result { + shared::run_blocking_with_timeout( + &state, + Duration::from_secs(30), + "Token validation did not complete within 30 seconds", + "validation", + move |app_state| auth::validate_provider_token(&app_state, &provider, &host), + ) + .await +} diff --git a/src-tauri/src/commands/dashboard.rs b/src-tauri/src/commands/dashboard.rs index 890cf97e..625e3004 100644 --- a/src-tauri/src/commands/dashboard.rs +++ b/src-tauri/src/commands/dashboard.rs @@ -63,6 +63,31 @@ pub async fn sync_gitlab( outcome } +#[tauri::command] +pub async fn sync_providers( + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let app_for_progress = app.clone(); + let outcome = shared::run_blocking_with_timeout( + &state, + Duration::from_secs(300), + "Provider sync did not complete within 5 minutes", + "sync", + move |app_state| { + let mut progress_fn = |msg: String| { + let _ = app_for_progress.emit(SYNC_PROGRESS_EVENT, &msg); + }; + sync::sync_providers(&app_state, &mut progress_fn) + }, + ) + .await; + if outcome.is_ok() { + reminders::kick_reminder_scheduler(&app); + } + outcome +} + #[tauri::command] pub fn update_schedule( state: State<'_, AppState>, diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index 0714f4e3..c89df8a0 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -7,6 +7,7 @@ use crate::{ }; const GITLAB_PROVIDER: &str = "GitLab"; +const YOUTRACK_PROVIDER: &str = "YouTrack"; const OAUTH_READY_NOTE: &str = "GitLab OAuth client is configured locally. Register timely://auth/gitlab as an allowed redirect URI in GitLab."; const OAUTH_MISSING_NOTE: &str = "Client ID missing. Configure a GitLab OAuth application before launching auth."; @@ -37,7 +38,7 @@ pub fn upsert_gitlab_connection( let now = utc_timestamp(); let oauth_ready = client_id.is_some(); - let status_note = oauth_status_note(client_id.is_some()); + let status_note = oauth_status_note_for_provider(GITLAB_PROVIDER, client_id.is_some()); match existing_id { Some(id) => { @@ -86,6 +87,82 @@ pub fn upsert_gitlab_connection( .ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) } +pub fn upsert_provider_connection( + connection: &Connection, + provider: &str, + host: &str, + display_name: Option<&str>, + client_id: Option<&str>, + auth_mode: &str, + preferred_scope: &str, +) -> Result { + let provider = normalize_provider(provider); + let normalized_host = normalize_host(host); + let display_name = display_name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| default_display_name(provider)) + .trim() + .to_string(); + + let existing_id = connection + .query_row( + "SELECT id FROM provider_accounts WHERE provider = ?1 AND host = ?2 LIMIT 1", + params![provider, normalized_host.as_str()], + |row| row.get::<_, i64>(0), + ) + .optional()?; + + let now = utc_timestamp(); + let oauth_ready = client_id.is_some(); + let status_note = oauth_status_note_for_provider(provider, client_id.is_some()); + + match existing_id { + Some(id) => { + connection.execute( + "UPDATE provider_accounts + SET display_name = ?1, oauth_client_id = ?2, auth_mode = ?3, preferred_scope = ?4, oauth_ready = ?5, status_note = ?6, last_sync_at = ?7 + WHERE id = ?8", + params![ + display_name, + client_id, + auth_mode, + preferred_scope, + bool_to_sqlite(oauth_ready), + status_note, + now, + id, + ], + )?; + } + None => { + connection.execute( + "UPDATE provider_accounts SET is_primary = 0 WHERE provider = ?1", + [provider], + )?; + connection.execute( + "INSERT INTO provider_accounts (provider, host, display_name, username, auth_mode, oauth_client_id, preferred_scope, oauth_ready, status_note, is_primary, created_at, last_sync_at) + VALUES (?1, ?2, ?3, NULL, ?4, ?5, ?6, ?7, ?8, 1, ?9, ?9)", + params![ + provider, + normalized_host, + display_name, + auth_mode, + client_id, + preferred_scope, + bool_to_sqlite(oauth_ready), + status_note, + now, + ], + )?; + } + } + + load_provider_connections(connection)? + .into_iter() + .find(|item| item.provider == provider && item.host == normalized_host) + .ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) +} + pub fn save_gitlab_pat( connection: &Connection, host: &str, @@ -143,6 +220,53 @@ pub fn save_gitlab_pat( .ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) } +pub fn save_provider_pat( + connection: &Connection, + provider: &str, + host: &str, + token: &str, +) -> Result { + let provider = normalize_provider(provider); + let normalized_host = normalize_host(host); + + let existing_id = connection + .query_row( + "SELECT id FROM provider_accounts WHERE provider = ?1 AND host = ?2 LIMIT 1", + params![provider, normalized_host.as_str()], + |row| row.get::<_, i64>(0), + ) + .optional()?; + + let now = utc_timestamp(); + let note = pat_connected_note(provider); + match existing_id { + Some(id) => { + connection.execute( + "UPDATE provider_accounts + SET auth_mode = 'PAT', personal_access_token = ?1, preferred_scope = 'api', oauth_ready = 1, status_note = ?2, last_sync_at = ?3 + WHERE id = ?4", + params![token, note, now, id], + )?; + } + None => { + connection.execute( + "UPDATE provider_accounts SET is_primary = 0 WHERE provider = ?1", + [provider], + )?; + connection.execute( + "INSERT INTO provider_accounts (provider, host, display_name, username, auth_mode, personal_access_token, preferred_scope, oauth_ready, status_note, is_primary, created_at, last_sync_at) + VALUES (?1, ?2, ?3, NULL, 'PAT', ?4, 'api', 1, ?5, 1, ?6, ?6)", + params![provider, normalized_host, normalized_host, token, note, now,], + )?; + } + } + + load_provider_connections(connection)? + .into_iter() + .find(|item| item.provider == provider && item.host == normalized_host) + .ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) +} + pub fn load_gitlab_connections( connection: &Connection, ) -> Result, AppError> { @@ -158,6 +282,17 @@ pub fn load_gitlab_connections( Ok(rows.collect::, _>>()?) } +pub fn load_provider_connections(connection: &Connection) -> Result, AppError> { + let mut statement = connection.prepare( + "SELECT id, provider, display_name, host, oauth_client_id, auth_mode, preferred_scope, oauth_ready, status_note, is_primary, personal_access_token, username + FROM provider_accounts + ORDER BY provider ASC, is_primary DESC, id ASC", + )?; + + let rows = statement.query_map([], map_provider_connection_row)?; + Ok(rows.collect::, _>>()?) +} + pub fn load_gitlab_token(connection: &Connection, host: &str) -> Result, AppError> { let normalized_host = normalize_host(host); let token = connection @@ -171,29 +306,82 @@ pub fn load_gitlab_token(connection: &Connection, host: &str) -> Result Result, AppError> { + let provider = normalize_provider(provider); + let normalized_host = normalize_host(host); + let token = connection + .query_row( + "SELECT personal_access_token FROM provider_accounts WHERE provider = ?1 AND host = ?2 LIMIT 1", + params![provider, normalized_host.as_str()], + |row| row.get::<_, Option>(0), + ) + .optional()?; + + Ok(token.flatten().filter(|t| !t.is_empty())) +} + pub fn update_username( connection: &Connection, host: &str, username: &str, ) -> Result<(), AppError> { + update_provider_username(connection, GITLAB_PROVIDER, host, username) +} + +pub fn update_provider_username( + connection: &Connection, + provider: &str, + host: &str, + username: &str, +) -> Result<(), AppError> { + let provider = normalize_provider(provider); let normalized_host = normalize_host(host); connection.execute( "UPDATE provider_accounts SET username = ?1, status_note = ?2 WHERE provider = ?3 AND host = ?4", params![ username, format!("Authenticated as @{username}"), - GITLAB_PROVIDER, + provider, normalized_host, ], )?; Ok(()) } -fn oauth_status_note(has_client_id: bool) -> &'static str { - if has_client_id { - OAUTH_READY_NOTE +fn oauth_status_note_for_provider(provider: &str, has_client_id: bool) -> &'static str { + match (normalize_provider(provider), has_client_id) { + (GITLAB_PROVIDER, true) => OAUTH_READY_NOTE, + (GITLAB_PROVIDER, false) => OAUTH_MISSING_NOTE, + (_, true) => "OAuth client is configured locally.", + (_, false) => "Use API token or configure OAuth before launching auth.", + } +} + +fn pat_connected_note(provider: &str) -> &'static str { + if normalize_provider(provider) == GITLAB_PROVIDER { + PAT_CONNECTED_NOTE + } else { + "Connected via API token." + } +} + +fn default_display_name(provider: &str) -> &'static str { + if normalize_provider(provider) == YOUTRACK_PROVIDER { + "YouTrack workspace" + } else { + "GitLab workspace" + } +} + +pub fn normalize_provider(provider: &str) -> &'static str { + if provider.eq_ignore_ascii_case("youtrack") { + YOUTRACK_PROVIDER } else { - OAUTH_MISSING_NOTE + GITLAB_PROVIDER } } diff --git a/src-tauri/src/domain/models.rs b/src-tauri/src/domain/models.rs index 69164b50..13762b26 100644 --- a/src-tauri/src/domain/models.rs +++ b/src-tauri/src/domain/models.rs @@ -47,6 +47,17 @@ pub struct GitLabConnectionInput { pub client_id: Option, } +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderConnectionInput { + pub provider: String, + pub host: String, + pub auth_mode: String, + pub preferred_scope: String, + pub display_name: Option, + pub client_id: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuthLaunchPlan { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9398e580..6e862e35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,8 +15,10 @@ use tauri_plugin_deep_link::DeepLinkExt; use crate::{ commands::{ auth::{ - begin_gitlab_oauth, list_gitlab_connections, resolve_gitlab_oauth_callback, - save_gitlab_connection, save_gitlab_pat, validate_gitlab_token, + begin_gitlab_oauth, list_gitlab_connections, list_provider_connections, + resolve_gitlab_oauth_callback, save_gitlab_connection, save_gitlab_pat, + save_provider_connection, save_provider_pat, validate_gitlab_token, + validate_provider_token, }, dashboard::{ activate_quest, bootstrap_dashboard, claim_quest_reward, diagnostics_clear, @@ -26,7 +28,8 @@ use crate::{ notification_delivery_profile, notification_permission_capability, notification_permission_state, notification_request_permission, notification_send_test, open_system_notification_settings, purchase_reward, reset_all_data, - save_app_preferences, save_setup_state, sync_gitlab, unequip_reward, update_schedule, + save_app_preferences, save_setup_state, sync_gitlab, sync_providers, unequip_reward, + update_schedule, }, issues::{ create_issue_comment, delete_issue, delete_issue_comment, load_issue_activity_page, @@ -177,12 +180,17 @@ pub fn run() { log_issue_time, bootstrap_dashboard, list_gitlab_connections, + list_provider_connections, save_gitlab_connection, save_gitlab_pat, + save_provider_connection, + save_provider_pat, validate_gitlab_token, + validate_provider_token, begin_gitlab_oauth, resolve_gitlab_oauth_callback, sync_gitlab, + sync_providers, update_schedule, load_setup_state, save_setup_state, diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 91e6a0eb..97236222 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -1 +1,2 @@ pub mod gitlab; +pub mod youtrack; diff --git a/src-tauri/src/providers/youtrack.rs b/src-tauri/src/providers/youtrack.rs new file mode 100644 index 00000000..cd6b1bea --- /dev/null +++ b/src-tauri/src/providers/youtrack.rs @@ -0,0 +1,71 @@ +use reqwest::blocking::Client; +use serde::Deserialize; + +use crate::error::AppError; + +#[derive(Clone, Debug)] +pub struct YouTrackClient { + host: String, + token: String, + http: Client, +} + +#[derive(Clone, Debug)] +pub struct YouTrackUser { + pub username: String, + pub name: String, + pub avatar_url: Option, +} + +impl YouTrackClient { + pub fn new(host: &str, token: &str) -> Result { + if host.trim().is_empty() { + return Err(AppError::GitLabApi("YouTrack host is required".to_string())); + } + if token.trim().is_empty() { + return Err(AppError::GitLabApi("YouTrack token is required".to_string())); + } + let http = Client::builder().build()?; + Ok(Self { + host: host.trim().trim_end_matches('/').to_string(), + token: token.to_string(), + http, + }) + } + + pub fn fetch_user(&self) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct MeResponse { + login: Option, + full_name: Option, + avatar_url: Option, + } + + let url = format!( + "https://{}/api/users/me?fields=login,fullName,avatarUrl", + self.host + ); + let response = self + .http + .get(url) + .header("Authorization", format!("Bearer {}", self.token)) + .header("Accept", "application/json") + .send()?; + + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack token validation failed with status {}", + response.status() + ))); + } + + let me = response.json::()?; + let username = me.login.unwrap_or_else(|| "youtrack-user".to_string()); + Ok(YouTrackUser { + name: me.full_name.unwrap_or_else(|| username.clone()), + username, + avatar_url: me.avatar_url, + }) + } +} diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index 458238dc..71119fdf 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -3,6 +3,7 @@ use crate::{ domain::models::{ AuthLaunchPlan, GitLabConnectionInput, GitLabUserInfo, OAuthCallbackPayload, OAuthCallbackResolution, ProviderConnection, + ProviderConnectionInput, }, error::AppError, providers::gitlab::GitLabClient, @@ -25,11 +26,32 @@ pub fn save_gitlab_connection( ) } +pub fn save_provider_connection( + state: &AppState, + input: ProviderConnectionInput, +) -> Result { + let connection = shared::open_connection(state)?; + db::connection::upsert_provider_connection( + &connection, + &input.provider, + &input.host, + input.display_name.as_deref(), + input.client_id.as_deref(), + &input.auth_mode, + &input.preferred_scope, + ) +} + pub fn load_gitlab_connections(state: &AppState) -> Result, AppError> { let connection = shared::open_connection(state)?; db::connection::load_gitlab_connections(&connection) } +pub fn load_provider_connections(state: &AppState) -> Result, AppError> { + let connection = shared::open_connection(state)?; + db::connection::load_provider_connections(&connection) +} + pub fn save_gitlab_pat( state: &AppState, host: &str, @@ -39,6 +61,16 @@ pub fn save_gitlab_pat( db::connection::save_gitlab_pat(&connection, host, token) } +pub fn save_provider_pat( + state: &AppState, + provider: &str, + host: &str, + token: &str, +) -> Result { + let connection = shared::open_connection(state)?; + db::connection::save_provider_pat(&connection, provider, host, token) +} + pub fn begin_gitlab_oauth( state: &AppState, input: GitLabConnectionInput, @@ -101,6 +133,28 @@ pub fn validate_gitlab_token(state: &AppState, host: &str) -> Result Result { + if provider.eq_ignore_ascii_case("youtrack") { + let connection = shared::open_connection(state)?; + let token = db::connection::load_provider_token(&connection, provider, host)? + .ok_or_else(|| AppError::GitLabApi("no token found for this host".to_string()))?; + let client = crate::providers::youtrack::YouTrackClient::new(host, &token)?; + let user = client.fetch_user()?; + db::connection::update_provider_username(&connection, provider, host, &user.username)?; + return Ok(GitLabUserInfo { + username: user.username, + name: user.name, + avatar_url: user.avatar_url, + }); + } + + validate_gitlab_token(state, host) +} + #[cfg(test)] mod tests { use std::{env, path::PathBuf}; diff --git a/src-tauri/src/services/issues.rs b/src-tauri/src/services/issues.rs index d76f4a5e..1e900e9b 100644 --- a/src-tauri/src/services/issues.rs +++ b/src-tauri/src/services/issues.rs @@ -2,9 +2,10 @@ use crate::{ db, domain::models::{ CachedIterationRecord, CreateIssueCommentInput, DeleteIssueCommentInput, DeleteIssueInput, - IssueActivityPage, IssueDetailsSnapshot, LoadIssueActivityPageInput, LoadIssueDetailsInput, - LoadIssueDetailsResponse, LogIssueTimeInput, UpdateIssueCommentInput, - UpdateIssueMetadataInput, + IssueActivityPage, IssueComposerCapabilities, IssueDetailsCapabilities, IssueDetailsSnapshot, + IssueMetadataCapability, IssueReference, IssueTimeTrackingCapabilities, + LoadIssueActivityPageInput, LoadIssueDetailsInput, LoadIssueDetailsResponse, LogIssueTimeInput, + UpdateIssueCommentInput, UpdateIssueMetadataInput, }, error::AppError, providers::gitlab::{enrich_and_dedupe_issue_iteration_options, GitLabClient}, @@ -52,6 +53,13 @@ pub fn load_issue_details( } Ok(response) } + "youtrack" => Ok(LoadIssueDetailsResponse::Full { + snapshot: Box::new(youtrack_limited_snapshot(IssueReference { + provider: "youtrack".to_string(), + issue_id: input.issue_id.clone(), + provider_issue_ref: input.issue_id.clone(), + })), + }), other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -70,6 +78,7 @@ pub fn update_issue_metadata( enrich_and_dedupe_issue_iteration_options(&mut snapshot, &catalog); Ok(snapshot) } + "youtrack" => Ok(youtrack_limited_snapshot(input.reference.clone())), other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -86,6 +95,9 @@ pub fn create_issue_comment( let client = load_gitlab_client(state)?; client.create_issue_note(&input.reference.provider_issue_ref, &input.body) } + "youtrack" => Err(AppError::GitLabApi( + "YouTrack comments are not available in this version yet.".to_string(), + )), other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -102,6 +114,9 @@ pub fn update_issue_comment( let client = load_gitlab_client(state)?; client.update_issue_note(&input.reference.issue_id, &input.note_id, &input.body) } + "youtrack" => Err(AppError::GitLabApi( + "YouTrack comment edits are not available in this version yet.".to_string(), + )), other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -118,6 +133,9 @@ pub fn delete_issue_comment( let client = load_gitlab_client(state)?; client.delete_issue_note(&input.reference.issue_id, &input.note_id) } + "youtrack" => Err(AppError::GitLabApi( + "YouTrack comment deletes are not available in this version yet.".to_string(), + )), other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -136,6 +154,9 @@ pub fn log_issue_time(state: &AppState, input: &LogIssueTimeInput) -> Result Err(AppError::GitLabApi( + "YouTrack time logging is not available in this version yet.".to_string(), + )), other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -149,6 +170,9 @@ pub fn delete_issue(state: &AppState, input: &DeleteIssueInput) -> Result<(), Ap let client = load_gitlab_client(state)?; client.delete_issue(&input.reference.issue_id) } + "youtrack" => Err(AppError::GitLabApi( + "YouTrack issue delete is not available in this version yet.".to_string(), + )), other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -165,9 +189,66 @@ pub fn load_issue_activity_page( let client = load_gitlab_client(state)?; client.load_issue_activity_page(&input.reference, input.page, 10) } + "youtrack" => Ok(IssueActivityPage { + items: vec![], + has_next_page: false, + next_page: None, + }), other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other ))), } } + +fn disabled_capability(reason: &str) -> IssueMetadataCapability { + IssueMetadataCapability { + enabled: false, + reason: Some(reason.to_string()), + options: vec![], + } +} + +fn youtrack_limited_snapshot(reference: IssueReference) -> IssueDetailsSnapshot { + let reason = "This YouTrack issue is connected, but full API parity is still in progress."; + IssueDetailsSnapshot { + key: reference.issue_id.clone(), + title: format!("YouTrack issue {}", reference.issue_id), + state: "open".to_string(), + reference, + author: None, + created_at: None, + updated_at: None, + web_url: None, + total_time_spent: None, + description: Some(reason.to_string()), + status: None, + status_options: None, + labels: vec![], + milestone_title: None, + milestone: None, + iteration: None, + linked_items: Some(vec![]), + child_items: Some(vec![]), + activity: vec![], + activity_has_next_page: Some(false), + activity_next_page: None, + viewer_username: None, + issue_etag: None, + capabilities: IssueDetailsCapabilities { + status: disabled_capability(reason), + labels: disabled_capability(reason), + iteration: disabled_capability(reason), + milestone: disabled_capability(reason), + composer: IssueComposerCapabilities { + enabled: false, + modes: vec!["write".to_string()], + supports_quick_actions: false, + }, + time_tracking: IssueTimeTrackingCapabilities { + enabled: false, + supports_quick_actions: false, + }, + }, + } +} diff --git a/src-tauri/src/services/shared.rs b/src-tauri/src/services/shared.rs index 12523454..4dcf1c16 100644 --- a/src-tauri/src/services/shared.rs +++ b/src-tauri/src/services/shared.rs @@ -13,9 +13,16 @@ pub fn open_connection(state: &AppState) -> Result { pub fn load_primary_gitlab_connection( connection: &Connection, ) -> Result { - db::connection::load_gitlab_connections(connection)? + load_primary_connection(connection, "GitLab") +} + +pub fn load_primary_connection( + connection: &Connection, + provider: &str, +) -> Result { + db::connection::load_provider_connections(connection)? .into_iter() - .find(|connection| connection.is_primary) + .find(|connection| connection.is_primary && connection.provider.eq_ignore_ascii_case(provider)) .ok_or_else(|| AppError::GitLabApi(PRIMARY_GITLAB_CONNECTION_ERROR.to_string())) } diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index bd0815b8..3e43ef86 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -266,6 +266,49 @@ pub fn sync_gitlab( }) } +pub fn sync_providers( + state: &AppState, + on_progress: &mut dyn FnMut(String), +) -> Result { + let connection = shared::open_connection(state)?; + let providers = db::connection::load_provider_connections(&connection)?; + + let mut totals = SyncResult { + projects_synced: 0, + entries_synced: 0, + issues_synced: 0, + assigned_issues_synced: 0, + }; + + let has_gitlab = providers + .iter() + .any(|p| p.provider.eq_ignore_ascii_case("gitlab") && p.has_token); + let has_youtrack = providers + .iter() + .any(|p| p.provider.eq_ignore_ascii_case("youtrack") && p.has_token); + + if has_gitlab { + on_progress("Starting GitLab sync...".to_string()); + let result = sync_gitlab(state, on_progress)?; + totals.projects_synced += result.projects_synced; + totals.entries_synced += result.entries_synced; + totals.issues_synced += result.issues_synced; + totals.assigned_issues_synced += result.assigned_issues_synced; + } + + if has_youtrack { + on_progress("YouTrack sync not fully implemented yet; keeping connection active.".to_string()); + } + + if !has_gitlab && !has_youtrack { + return Err(AppError::GitLabApi( + "No active provider connections with tokens.".to_string(), + )); + } + + Ok(totals) +} + struct AssignedIssueSyncSummary { assigned_issues_synced: u32, active_group_ids: Vec, diff --git a/src/app/desktop/TauriService/tauri.ts b/src/app/desktop/TauriService/tauri.ts index 06d1906b..eede2f6b 100644 --- a/src/app/desktop/TauriService/tauri.ts +++ b/src/app/desktop/TauriService/tauri.ts @@ -28,6 +28,8 @@ import type { OAuthCallbackResolution, DiagnosticLogEntry, ProviderConnection, + ProviderConnectionInput, + ProviderKey, PlaySnapshot, PurchaseRewardInput, UnequipRewardInput, @@ -80,12 +82,22 @@ export async function listGitLabConnections(): Promise { return invokeTauri("list_gitlab_connections"); } +export async function listProviderConnections(): Promise { + return invokeTauri("list_provider_connections"); +} + export async function saveGitLabConnection( input: GitLabConnectionInput, ): Promise { return invokeTauri("save_gitlab_connection", { input }); } +export async function saveProviderConnection( + input: ProviderConnectionInput, +): Promise { + return invokeTauri("save_provider_connection", { input }); +} + export async function beginGitLabOAuth(input: GitLabConnectionInput): Promise { return invokeTauri("begin_gitlab_oauth", { input }); } @@ -102,6 +114,14 @@ export async function saveGitLabPat(host: string, token: string): Promise("save_gitlab_pat", { host, token }); } +export async function saveProviderPat( + provider: ProviderKey, + host: string, + token: string, +): Promise { + return invokeTauri("save_provider_pat", { provider, host, token }); +} + export async function listenForGitLabOAuthCallback( onSuccess: (payload: OAuthCallbackResolution) => void, onError: (message: string) => void, @@ -138,10 +158,21 @@ export async function validateGitLabToken(host: string): Promise return invokeTauri("validate_gitlab_token", { host }); } +export async function validateProviderToken( + provider: ProviderKey, + host: string, +): Promise { + return invokeTauri("validate_provider_token", { provider, host }); +} + export async function syncGitLab(): Promise { return invokeTauri("sync_gitlab"); } +export async function syncProviders(): Promise { + return invokeTauri("sync_providers"); +} + function mergeIssueDetailsNotModified( base: IssueDetailsSnapshot, delta: Extract, diff --git a/src/app/root/App/App.test.tsx b/src/app/root/App/App.test.tsx index fd068a9c..0f061af5 100644 --- a/src/app/root/App/App.test.tsx +++ b/src/app/root/App/App.test.tsx @@ -53,7 +53,7 @@ vi.mock("@/app/desktop/TauriService/tauri", async () => { ); return { ...actual, - listGitLabConnections: vi.fn(async () => []), + listProviderConnections: vi.fn(async () => []), loadBootstrapPayload: vi.fn(async () => mockBootstrap), loadAppPreferences: vi.fn(async () => ({ themeMode: "system", @@ -428,7 +428,7 @@ beforeEach(async () => { resetWorklogSnapshotCache(); globalThis.localStorage.clear(); vi.mocked(tauriModule.loadSetupState).mockReset().mockResolvedValue(COMPLETE_SETUP); - vi.mocked(tauriModule.listGitLabConnections).mockReset().mockResolvedValue([]); + vi.mocked(tauriModule.listProviderConnections).mockReset().mockResolvedValue([]); vi.mocked(tauriModule.loadBootstrapPayload).mockReset().mockResolvedValue(mockBootstrap); vi.mocked(tauriModule.loadAppPreferences) .mockReset() diff --git a/src/app/root/App/App.tsx b/src/app/root/App/App.tsx index 9c282141..7054bdf1 100644 --- a/src/app/root/App/App.tsx +++ b/src/app/root/App/App.tsx @@ -24,11 +24,11 @@ import { restartApp, resolveGitLabOAuthCallback, resetAllData, - saveGitLabConnection, - saveGitLabPat, + saveProviderConnection, + saveProviderPat, updateSchedule, updateTrayIcon, - validateGitLabToken, + validateProviderToken, } from "@/app/desktop/TauriService/tauri"; import { MainLayout } from "@/app/layouts/MainLayout/MainLayout"; import { useI18n } from "@/app/providers/I18nService/i18n"; @@ -56,7 +56,7 @@ import { TooltipProvider } from "@/shared/ui/Tooltip/Tooltip"; import type { BootstrapPayload, - GitLabConnectionInput, + ProviderConnectionInput, WorklogMode, } from "@/shared/types/dashboard"; @@ -502,21 +502,29 @@ function SettingsRoute() { connections={connections} syncState={syncState} onStartSync={startSync} - onSaveConnection={async (input: GitLabConnectionInput) => { - const saved = await saveGitLabConnection(input); + onSaveConnection={async (input: ProviderConnectionInput) => { + const saved = await saveProviderConnection(input); await refreshConnections(); return saved; }} - onSavePat={async (host: string, token: string) => { - const saved = await saveGitLabPat(host, token); + onSavePat={async (provider, host: string, token: string) => { + const saved = await saveProviderPat(provider, host, token); await refreshConnections(); return saved; }} - onBeginOAuth={beginGitLabOAuth} + onBeginOAuth={(input) => + beginGitLabOAuth({ + host: input.host, + authMode: input.authMode, + preferredScope: input.preferredScope, + displayName: input.displayName, + clientId: input.clientId, + }) + } onResolveCallback={(sessionId: string, callbackUrl: string) => resolveGitLabOAuthCallback({ sessionId, callbackUrl }) } - onValidateToken={validateGitLabToken} + onValidateToken={validateProviderToken} onListenOAuthEvents={listenForGitLabOAuthCallback} onCheckForUpdates={checkForAppUpdateChannel} onInstallUpdate={downloadAndInstallAppUpdate} diff --git a/src/app/routes/SetupRoutes/SetupRoutes.tsx b/src/app/routes/SetupRoutes/SetupRoutes.tsx index 9f510606..a1d4001f 100644 --- a/src/app/routes/SetupRoutes/SetupRoutes.tsx +++ b/src/app/routes/SetupRoutes/SetupRoutes.tsx @@ -10,10 +10,10 @@ import { beginGitLabOAuth, listenForGitLabOAuthCallback, resolveGitLabOAuthCallback, - saveGitLabConnection, - saveGitLabPat, + saveProviderConnection, + saveProviderPat, updateSchedule, - validateGitLabToken, + validateProviderToken, } from "@/app/desktop/TauriService/tauri"; import { SetupShell } from "@/app/layouts/SetupLayout/components/SetupShell/SetupShell"; import { @@ -45,7 +45,7 @@ import { hasActiveConnection } from "@/shared/types/dashboard"; import type { BootstrapPayload, - GitLabConnectionInput, + ProviderConnectionInput, ScheduleInput, SetupState, TimeFormat, @@ -146,21 +146,29 @@ export function SetupProviderRouteComponent() { navigate({ to: "/setup/sync" }); persistSetupStep(completeSetupStep, "provider", t); }} - onSaveConnection={async (input: GitLabConnectionInput) => { - const saved = await saveGitLabConnection(input); + onSaveConnection={async (input: ProviderConnectionInput) => { + const saved = await saveProviderConnection(input); await refreshConnections(); return saved; }} - onSavePat={async (host: string, token: string) => { - const saved = await saveGitLabPat(host, token); + onSavePat={async (provider, host: string, token: string) => { + const saved = await saveProviderPat(provider, host, token); await refreshConnections(); return saved; }} - onBeginOAuth={beginGitLabOAuth} + onBeginOAuth={(input) => + beginGitLabOAuth({ + host: input.host, + authMode: input.authMode, + preferredScope: input.preferredScope, + displayName: input.displayName, + clientId: input.clientId, + }) + } onResolveCallback={(sessionId: string, callbackUrl: string) => resolveGitLabOAuthCallback({ sessionId, callbackUrl }) } - onValidateToken={validateGitLabToken} + onValidateToken={validateProviderToken} onListenOAuthEvents={listenForGitLabOAuthCallback} /> ); diff --git a/src/app/state/AppStore/app-store.test.ts b/src/app/state/AppStore/app-store.test.ts index adf7b7c6..3391bc71 100644 --- a/src/app/state/AppStore/app-store.test.ts +++ b/src/app/state/AppStore/app-store.test.ts @@ -9,9 +9,9 @@ vi.mock("@/app/desktop/TauriService/tauri", async () => { ); return { ...actual, - syncGitLab: vi.fn(), + syncProviders: vi.fn(), listenSyncProgress: vi.fn(async () => () => {}), - listGitLabConnections: vi.fn(async () => []), + listProviderConnections: vi.fn(async () => []), loadBootstrapPayload: vi.fn(async () => { const { mockBootstrap } = await import("@/test/fixtures/mock-data"); return mockBootstrap; @@ -64,7 +64,7 @@ function resetStore() { beforeEach(() => { resetStore(); - vi.mocked(tauriModule.syncGitLab).mockReset().mockResolvedValue(MOCK_RESULT); + vi.mocked(tauriModule.syncProviders).mockReset().mockResolvedValue(MOCK_RESULT); vi.mocked(tauriModule.listenSyncProgress) .mockReset() .mockResolvedValue(() => {}); @@ -108,7 +108,7 @@ describe("startSync", () => { }); it("does NOT increment syncVersion when sync fails", async () => { - vi.mocked(tauriModule.syncGitLab).mockRejectedValue(new Error("network error")); + vi.mocked(tauriModule.syncProviders).mockRejectedValue(new Error("network error")); await useAppStore.getState().startSync(); @@ -122,8 +122,8 @@ describe("startSync", () => { await useAppStore.getState().startSync(); - // syncGitLab should NOT have been called since we were already syncing - expect(tauriModule.syncGitLab).not.toHaveBeenCalled(); + // syncProviders should NOT have been called since we were already syncing + expect(tauriModule.syncProviders).not.toHaveBeenCalled(); }); it("appends a summary line to the log on success", async () => { diff --git a/src/app/state/AppStore/app-store.ts b/src/app/state/AppStore/app-store.ts index a05107cc..60e6c33c 100644 --- a/src/app/state/AppStore/app-store.ts +++ b/src/app/state/AppStore/app-store.ts @@ -5,7 +5,7 @@ import { } from "@/app/bootstrap/PreferencesCache/preferences-cache"; import { clearStartupAppSnapshot } from "@/app/bootstrap/StartupAppState/startup-app-state"; import { - listGitLabConnections, + listProviderConnections, loadSetupState, loadBootstrapPayload, saveSetupState, @@ -42,7 +42,7 @@ export const useAppStore = create((set, get) => ({ bootstrap: createBootstrapAction(set, get), refreshConnections: async () => { - const next = await listGitLabConnections(); + const next = await listProviderConnections(); set({ connections: next }); persistStartupSnapshotFromStore(get()); }, @@ -50,7 +50,7 @@ export const useAppStore = create((set, get) => ({ refreshPayload: async () => { const [payload, connections, setupState] = await Promise.all([ loadBootstrapPayload(), - listGitLabConnections(), + listProviderConnections(), loadSetupState(), ]); set((state) => ({ diff --git a/src/app/state/AppStore/internal/app-store-actions.ts b/src/app/state/AppStore/internal/app-store-actions.ts index c2a512bd..7abc77b4 100644 --- a/src/app/state/AppStore/internal/app-store-actions.ts +++ b/src/app/state/AppStore/internal/app-store-actions.ts @@ -5,7 +5,7 @@ import { } from "@/app/bootstrap/PreferencesCache/preferences-cache"; import { syncStartupPrefsWithPreferences } from "@/app/bootstrap/StartupPrefs/startup-prefs"; import { - listGitLabConnections, + listProviderConnections, listenSyncProgress, loadAppPreferences, loadBootstrapPayload, @@ -13,7 +13,7 @@ import { logFrontendBootTiming, requestNotificationPermission, saveSetupState, - syncGitLab, + syncProviders, } from "@/app/desktop/TauriService/tauri"; import { persistStartupSnapshot, @@ -49,7 +49,7 @@ export function createBootstrapAction(set: AppStoreSet, get: AppStoreGet) { try { let [payload, connections, setupState, preferences] = await Promise.all([ timedStoreCall("bootstrap_dashboard", () => loadBootstrapPayload()), - timedStoreCall("list_gitlab_connections", () => listGitLabConnections()), + timedStoreCall("list_provider_connections", () => listProviderConnections()), timedStoreCall("load_setup_state", () => loadSetupState()), timedStoreCall("load_app_preferences", () => loadAppPreferences()), ]); @@ -146,7 +146,7 @@ export function createStartSyncAction(set: AppStoreSet, get: AppStoreGet) { } try { - const result = await syncGitLab(); + const result = await syncProviders(); const current = get().syncState; set({ syncState: { diff --git a/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx new file mode 100644 index 00000000..7421bed2 --- /dev/null +++ b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { I18nProvider } from "@/app/providers/I18nService/i18n"; +import { YouTrackAuthPanel } from "@/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel"; + +describe("YouTrackAuthPanel", () => { + it("connects with host and token", async () => { + const onSaveConnection = vi.fn().mockResolvedValue({ + id: 2, + provider: "YouTrack", + displayName: "YouTrack workspace", + host: "company.youtrack.cloud", + hasToken: true, + state: "live", + authMode: "PAT", + preferredScope: "api", + statusNote: "Connected", + oauthReady: true, + isPrimary: true, + }); + const onSavePat = vi.fn().mockResolvedValue({ + id: 2, + provider: "YouTrack", + displayName: "YouTrack workspace", + host: "company.youtrack.cloud", + hasToken: true, + state: "live", + authMode: "PAT", + preferredScope: "api", + statusNote: "Connected", + oauthReady: true, + isPrimary: true, + }); + + render( + + + , + ); + + fireEvent.change(screen.getByPlaceholderText("your-company.youtrack.cloud"), { + target: { value: "company.youtrack.cloud" }, + }); + fireEvent.change(screen.getByPlaceholderText("perm:xxxxxxxx"), { + target: { value: "perm:token" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect YouTrack" })); + + await waitFor(() => { + expect(onSaveConnection).toHaveBeenCalled(); + expect(onSavePat).toHaveBeenCalledWith("youtrack", "company.youtrack.cloud", "perm:token"); + }); + }); +}); diff --git a/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx new file mode 100644 index 00000000..abff5646 --- /dev/null +++ b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { useI18n } from "@/app/providers/I18nService/i18n"; +import { Button } from "@/shared/ui/Button/Button"; + +import type { ProviderConnection, ProviderConnectionInput } from "@/shared/types/dashboard"; + +interface YouTrackAuthPanelProps { + connections: ProviderConnection[]; + onSaveConnection: (input: ProviderConnectionInput) => Promise; + onSavePat: (provider: "youtrack", host: string, token: string) => Promise; + onValidateToken?: (provider: "youtrack", host: string) => Promise<{ username: string }>; +} + +export function YouTrackAuthPanel({ + connections, + onSaveConnection, + onSavePat, + onValidateToken, +}: Readonly) { + const { t } = useI18n(); + const youTrackConnection = connections.find((item) => item.provider.toLowerCase() === "youtrack"); + const [host, setHost] = useState(youTrackConnection?.host ?? ""); + const [token, setToken] = useState(""); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(null); + const connected = youTrackConnection?.hasToken === true; + + async function handleConnect() { + if (host.trim().length === 0 || token.trim().length === 0) { + setStatus(t("settings.tryAgain")); + return; + } + setBusy(true); + setStatus(null); + try { + await onSaveConnection({ + provider: "youtrack", + host, + authMode: "PAT", + preferredScope: "api", + displayName: "YouTrack workspace", + }); + await onSavePat("youtrack", host, token); + if (onValidateToken) { + await onValidateToken("youtrack", host); + } + setStatus("YouTrack connected."); + setToken(""); + } catch (error) { + setStatus(error instanceof Error ? error.message : t("settings.tryAgain")); + } finally { + setBusy(false); + } + } + + return ( +
+
+

+ Connect with permanent token. OAuth not required. +

+
+ + +
+ + {connected ? Connected : null} +
+ {status ?

{status}

: null} +
+ ); +} diff --git a/src/features/issues/screens/IssueHubPage/IssueHubPage.tsx b/src/features/issues/screens/IssueHubPage/IssueHubPage.tsx index 025db5fb..4ebbf06c 100644 --- a/src/features/issues/screens/IssueHubPage/IssueHubPage.tsx +++ b/src/features/issues/screens/IssueHubPage/IssueHubPage.tsx @@ -20,6 +20,7 @@ import { IssueDetailsMainSection } from "@/features/issues/sections/IssueDetails import { IssueDetailsSidebarSection } from "@/features/issues/sections/IssueDetailsSidebarSection/IssueDetailsSidebarSection"; import { getAssignedIssueStateBadgeClassName } from "@/features/issues/ui/AssignedIssuesBoard/lib/assigned-issue-badge-tone"; import { IssueDetailsSkeleton } from "@/features/issues/ui/IssueDetailsSkeleton/IssueDetailsSkeleton"; +import { IssueOriginBadge } from "@/features/issues/ui/IssueOriginBadge/IssueOriginBadge"; import { staggerItem } from "@/shared/lib/animations/animations"; import { cn } from "@/shared/lib/utils"; import { Badge } from "@/shared/ui/Badge/Badge"; @@ -223,7 +224,7 @@ export function IssueHubPage({ const detailsUrl = controller.details?.webUrl ?? null; const canEditIssueDescription = controller.loadState.status === "ready" && - controller.loadState.details.reference.provider === "gitlab"; + controller.loadState.details.capabilities.composer.enabled; useEffect(() => { setStickyVisible(false); @@ -291,6 +292,9 @@ export function IssueHubPage({ {statusLabel(controller.details.state, t)} ) : null} + {controller.details ? ( + + ) : null}

{controller.details?.title ?? t("issues.hubPageTitle")}

@@ -309,6 +313,7 @@ export function IssueHubPage({ open={stickyMenuOpen} onOpenChange={setStickyMenuOpen} canEditIssueDescription={canEditIssueDescription} + provider={controller.details?.reference.provider ?? issueReference.provider} detailsUrl={detailsUrl} busy={controller.busyAction !== null} onEditDescription={startDescriptionEdit} @@ -391,6 +396,7 @@ export function IssueHubPage({ > {statusLabel(controller.details.state, t)} + {controller.details.key} {controller.details.author && controller.details.createdAt ? ( @@ -419,6 +425,7 @@ export function IssueHubPage({ open={menuOpen} onOpenChange={setMenuOpen} canEditIssueDescription={canEditIssueDescription} + provider={controller.details?.reference.provider ?? issueReference.provider} detailsUrl={detailsUrl} busy={controller.busyAction !== null} onEditDescription={startDescriptionEdit} @@ -458,7 +465,7 @@ export function IssueHubPage({ onDescriptionComposerModeChange={setDescriptionComposerMode} onCancelDescriptionEdit={cancelDescriptionEdit} onSaveDescription={ - controller.loadState.details.reference.provider === "gitlab" + controller.loadState.details.capabilities.composer.enabled ? handleSaveDescription : undefined } @@ -530,6 +537,7 @@ function IssueActionsMenu({ open, onOpenChange, canEditIssueDescription, + provider, detailsUrl, busy, onEditDescription, @@ -538,6 +546,7 @@ function IssueActionsMenu({ open: boolean; onOpenChange: (open: boolean) => void; canEditIssueDescription: boolean; + provider: string; detailsUrl: string | null; busy: boolean; onEditDescription: () => void; @@ -582,7 +591,7 @@ function IssueActionsMenu({ }} > - {t("issues.openInGitLab")} + {provider.toLowerCase() === "youtrack" ? "Open in YouTrack" : t("issues.openInGitLab")} ) : null} + + + {provider === "gitlab" ? ( + item.provider.toLowerCase() === "gitlab")} + onSaveConnection={(input) => onSaveConnection({ ...input, provider: "gitlab" })} + onSavePat={(host, token) => onSavePat("gitlab", host, token)} + onBeginOAuth={(input) => onBeginOAuth({ ...input, provider: "gitlab" })} + onResolveCallback={onResolveCallback} + onValidateToken={onValidateToken ? (host) => onValidateToken("gitlab", host) : undefined} + onListenOAuthEvents={onListenOAuthEvents} + /> + ) : ( + + )} ); diff --git a/src/features/setup/screens/SetupProviderPage/SetupProviderPage.tsx b/src/features/setup/screens/SetupProviderPage/SetupProviderPage.tsx index 6335c2f3..417fb3b0 100644 --- a/src/features/setup/screens/SetupProviderPage/SetupProviderPage.tsx +++ b/src/features/setup/screens/SetupProviderPage/SetupProviderPage.tsx @@ -1,25 +1,28 @@ import { useI18n } from "@/app/providers/I18nService/i18n"; import { GitLabAuthPanel } from "@/domains/gitlab-connection/ui/GitLabAuthPanel/GitLabAuthPanel"; +import { YouTrackAuthPanel } from "@/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel"; import { hasActiveConnection } from "@/shared/types/dashboard"; import { Button } from "@/shared/ui/Button/Button"; +import { useState } from "react"; import type { AuthLaunchPlan, - GitLabConnectionInput, GitLabUserInfo, OAuthCallbackResolution, + ProviderConnectionInput, ProviderConnection, + ProviderKey, } from "@/shared/types/dashboard"; interface SetupProviderPageProps { connections: ProviderConnection[]; onBack: () => void; onNext: () => void; - onSaveConnection: (input: GitLabConnectionInput) => Promise; - onSavePat: (host: string, token: string) => Promise; - onBeginOAuth: (input: GitLabConnectionInput) => Promise; + onSaveConnection: (input: ProviderConnectionInput) => Promise; + onSavePat: (provider: ProviderKey, host: string, token: string) => Promise; + onBeginOAuth: (input: ProviderConnectionInput) => Promise; onResolveCallback: (sessionId: string, callbackUrl: string) => Promise; - onValidateToken?: (host: string) => Promise; + onValidateToken?: (provider: ProviderKey, host: string) => Promise; onListenOAuthEvents?: ( onSuccess: (payload: OAuthCallbackResolution) => void, onError: (message: string) => void, @@ -39,6 +42,7 @@ export function SetupProviderPage({ }: Readonly) { const { t } = useI18n(); const hasConnection = hasActiveConnection(connections); + const [provider, setProvider] = useState("gitlab"); return (
@@ -48,15 +52,35 @@ export function SetupProviderPage({
- +
+ + +
+ {provider === "gitlab" ? ( + item.provider.toLowerCase() === "gitlab")} + onSaveConnection={(input) => onSaveConnection({ ...input, provider: "gitlab" })} + onSavePat={(host, token) => onSavePat("gitlab", host, token)} + onBeginOAuth={(input) => onBeginOAuth({ ...input, provider: "gitlab" })} + onResolveCallback={onResolveCallback} + onValidateToken={onValidateToken ? (host) => onValidateToken("gitlab", host) : undefined} + onListenOAuthEvents={onListenOAuthEvents} + /> + ) : ( + + )}
diff --git a/src/shared/types/dashboard.ts b/src/shared/types/dashboard.ts index 56ee3e2d..c92073ca 100644 --- a/src/shared/types/dashboard.ts +++ b/src/shared/types/dashboard.ts @@ -326,6 +326,17 @@ export interface ProviderConnection { isPrimary: boolean; } +export type ProviderKey = "gitlab" | "youtrack"; + +export interface ProviderConnectionInput { + provider: ProviderKey; + host: string; + authMode: string; + preferredScope: string; + displayName?: string; + clientId?: string; +} + /** Whether a single connection has usable credentials (PAT or OAuth). */ export function isConnectionActive(c: ProviderConnection): boolean { return c.hasToken || Boolean(c.clientId); From 048538b69d1c53709c0c194580e5d498e5da7164 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 11:53:33 -0400 Subject: [PATCH 02/44] feat(youtrack): enhance YouTrack integration with issue management functionalities - Implemented new methods for loading issue details, creating, updating, and deleting comments, and logging time in YouTrack. - Added support for fetching issue activity pages and managing issue metadata updates. - Refactored the Tauri service to integrate YouTrack functionalities, allowing seamless interaction with YouTrack issues. - Updated existing components to utilize the new YouTrack client, improving overall issue management capabilities. - Enhanced error handling for YouTrack API interactions to provide clearer feedback on failures. --- src-tauri/src/providers/youtrack.rs | 457 +++++++++++++++++++++++++++- src-tauri/src/services/issues.rs | 126 +++----- src-tauri/src/services/sync.rs | 49 ++- 3 files changed, 552 insertions(+), 80 deletions(-) diff --git a/src-tauri/src/providers/youtrack.rs b/src-tauri/src/providers/youtrack.rs index cd6b1bea..d6b08686 100644 --- a/src-tauri/src/providers/youtrack.rs +++ b/src-tauri/src/providers/youtrack.rs @@ -1,7 +1,16 @@ use reqwest::blocking::Client; use serde::Deserialize; +use serde_json::json; -use crate::error::AppError; +use crate::{ + domain::models::{ + AssignedIssueRecord, IssueActivityItem, IssueActivityPage, IssueActor, + IssueComposerCapabilities, IssueDetailsCapabilities, IssueDetailsSnapshot, + IssueMetadataCapability, IssueMetadataOption, IssueReference, IssueTimeTrackingCapabilities, + IssueStatusOption, UpdateIssueMetadataInput, + }, + error::AppError, +}; #[derive(Clone, Debug)] pub struct YouTrackClient { @@ -17,6 +26,63 @@ pub struct YouTrackUser { pub avatar_url: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackIssue { + id: String, + id_readable: String, + summary: Option, + description: Option, + created: Option, + updated: Option, + resolved: Option, + tags: Option>, + comments: Option>, + custom_fields: Option>, +} + +#[derive(Deserialize)] +struct YouTrackTag { + name: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackComment { + id: String, + text: Option, + created: Option, + updated: Option, + author: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackAuthor { + login: Option, + full_name: Option, + avatar_url: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackCustomField { + name: Option, + value: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackFieldValue { + name: Option, + text: Option, +} + +#[derive(Deserialize)] +struct YouTrackCreatedItem { + id: Option, +} + impl YouTrackClient { pub fn new(host: &str, token: &str) -> Result { if host.trim().is_empty() { @@ -68,4 +134,393 @@ impl YouTrackClient { avatar_url: me.avatar_url, }) } + + pub fn load_issue_details(&self, reference: &IssueReference) -> Result { + let issue = self.fetch_issue(&reference.issue_id)?; + Ok(self.map_issue_details(reference, issue)) + } + + pub fn create_issue_comment(&self, issue_id: &str, body: &str) -> Result { + let url = format!("{}/api/issues/{issue_id}/comments?fields=id", self.base_url()); + let response = self + .authorized(self.http.post(url)) + .json(&json!({ "text": body })) + .send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack create comment failed with status {}", + response.status() + ))); + } + let payload = response.json::()?; + Ok(payload.id.unwrap_or_else(|| "created".to_string())) + } + + pub fn update_issue_comment( + &self, + issue_id: &str, + comment_id: &str, + body: &str, + ) -> Result<(), AppError> { + let url = format!( + "{}/api/issues/{issue_id}/comments/{comment_id}?fields=id", + self.base_url() + ); + let response = self + .authorized(self.http.post(url)) + .json(&json!({ "text": body })) + .send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack update comment failed with status {}", + response.status() + ))); + } + Ok(()) + } + + pub fn delete_issue_comment(&self, issue_id: &str, comment_id: &str) -> Result<(), AppError> { + let url = format!("{}/api/issues/{issue_id}/comments/{comment_id}", self.base_url()); + let response = self.authorized(self.http.delete(url)).send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack delete comment failed with status {}", + response.status() + ))); + } + Ok(()) + } + + pub fn load_issue_activity_page( + &self, + reference: &IssueReference, + page: u32, + per_page: u32, + ) -> Result { + let skip = page.saturating_sub(1) * per_page; + let url = format!( + "{}/api/issues/{}/comments?$top={}&$skip={}&fields=id,text,created,updated,author(login,fullName,avatarUrl)", + self.base_url(), + reference.issue_id, + per_page, + skip + ); + let response = self.authorized(self.http.get(url)).send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack activity load failed with status {}", + response.status() + ))); + } + let comments = response.json::>()?; + let items = comments + .iter() + .map(|comment| IssueActivityItem { + id: comment.id.clone(), + kind: "comment".to_string(), + body: comment.text.clone().unwrap_or_default(), + created_at: comment + .created + .map(to_iso) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()), + updated_at: comment.updated.map(to_iso), + system: false, + author: comment.author.as_ref().map(map_author), + }) + .collect::>(); + + Ok(IssueActivityPage { + has_next_page: comments.len() as u32 == per_page, + next_page: if comments.len() as u32 == per_page { + Some(page + 1) + } else { + None + }, + items, + }) + } + + pub fn log_issue_time( + &self, + issue_id: &str, + time_spent: &str, + summary: Option<&str>, + ) -> Result { + let query = match summary { + Some(text) if !text.trim().is_empty() => format!("work {time_spent} {text}"), + _ => format!("work {time_spent}"), + }; + let url = format!("{}/api/commands?fields=id", self.base_url()); + let response = self + .authorized(self.http.post(url)) + .json(&json!({ + "query": query, + "issues": [{ "idReadable": issue_id }], + })) + .send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack log time failed with status {}", + response.status() + ))); + } + Ok("Time logged in YouTrack.".to_string()) + } + + pub fn update_issue_metadata( + &self, + input: &UpdateIssueMetadataInput, + ) -> Result { + let issue_id = &input.reference.issue_id; + if let Some(description) = input.description.as_ref() { + let url = format!("{}/api/issues/{issue_id}?fields=id,idReadable", self.base_url()); + let response = self + .authorized(self.http.post(url)) + .json(&json!({ "description": description })) + .send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack description update failed with status {}", + response.status() + ))); + } + } + + if let Some(state) = input.state.as_ref() { + self.run_command(issue_id, &format!("State {state}"))?; + } + + self.load_issue_details(&input.reference) + } + + pub fn fetch_open_assigned_issues(&self) -> Result, AppError> { + let url = format!( + "{}/api/issues?query=for:%20me%20%23Unresolved&$top=100&fields=id,idReadable,summary,updated,resolved,tags(name)", + self.base_url() + ); + let response = self.authorized(self.http.get(url)).send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack assigned issues failed with status {}", + response.status() + ))); + } + let issues = response.json::>()?; + Ok(issues + .into_iter() + .map(|issue| AssignedIssueRecord { + issue_graphql_id: issue.id.clone(), + provider_item_id: issue.id_readable.clone(), + title: issue.summary.unwrap_or_else(|| issue.id_readable.clone()), + state: resolve_state(issue.custom_fields.as_deref()), + closed_at: issue.resolved.map(to_iso), + updated_at: issue.updated.map(to_iso), + web_url: Some(format!("{}/issue/{}", self.base_url(), issue.id_readable)), + labels: issue + .tags + .unwrap_or_default() + .into_iter() + .map(|tag| tag.name) + .collect(), + milestone_title: None, + iteration_gitlab_id: None, + iteration_group_id: None, + iteration_cadence_id: None, + iteration_cadence_title: None, + iteration_title: None, + iteration_start_date: None, + iteration_due_date: None, + }) + .collect()) + } + + fn fetch_issue(&self, issue_id: &str) -> Result { + let url = format!( + "{}/api/issues/{issue_id}?fields=id,idReadable,summary,description,created,updated,resolved,tags(name),comments(id,text,created,updated,author(login,fullName,avatarUrl)),customFields(name,value(name,text))", + self.base_url() + ); + let response = self.authorized(self.http.get(url)).send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack issue load failed with status {}", + response.status() + ))); + } + Ok(response.json::()?) + } + + fn map_issue_details(&self, reference: &IssueReference, issue: YouTrackIssue) -> IssueDetailsSnapshot { + let issue_id_for_url = issue.id_readable.clone(); + let state = resolve_state(issue.custom_fields.as_deref()); + let labels = issue + .tags + .unwrap_or_default() + .into_iter() + .map(|tag| IssueMetadataOption { + id: tag.name.clone(), + label: tag.name, + color: None, + badge: None, + }) + .collect::>(); + let activity = issue + .comments + .unwrap_or_default() + .into_iter() + .map(|comment| IssueActivityItem { + id: comment.id, + kind: "comment".to_string(), + body: comment.text.unwrap_or_default(), + created_at: comment + .created + .map(to_iso) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()), + updated_at: comment.updated.map(to_iso), + system: false, + author: comment.author.as_ref().map(map_author), + }) + .collect::>(); + + let status_options = vec![ + IssueStatusOption { + id: "Open".to_string(), + label: "Open".to_string(), + color: None, + icon: None, + }, + IssueStatusOption { + id: "In Progress".to_string(), + label: "In Progress".to_string(), + color: None, + icon: None, + }, + IssueStatusOption { + id: "Done".to_string(), + label: "Done".to_string(), + color: None, + icon: None, + }, + ]; + + IssueDetailsSnapshot { + reference: reference.clone(), + key: issue.id_readable.clone(), + title: issue.summary.unwrap_or_else(|| issue.id_readable.clone()), + state: state.clone(), + author: None, + created_at: issue.created.map(to_iso), + updated_at: issue.updated.map(to_iso), + web_url: Some(format!("{}/issue/{}", self.base_url(), issue_id_for_url)), + total_time_spent: None, + description: issue.description, + status: Some(IssueStatusOption { + id: state.clone(), + label: state, + color: None, + icon: None, + }), + status_options: Some(status_options), + labels, + milestone_title: None, + milestone: None, + iteration: None, + linked_items: Some(vec![]), + child_items: Some(vec![]), + activity, + activity_has_next_page: Some(false), + activity_next_page: None, + viewer_username: None, + issue_etag: None, + capabilities: IssueDetailsCapabilities { + status: IssueMetadataCapability { + enabled: true, + reason: None, + options: vec![], + }, + labels: IssueMetadataCapability { + enabled: false, + reason: Some("Label updates are not available yet for YouTrack.".to_string()), + options: vec![], + }, + iteration: disabled_capability("Iterations unavailable from YouTrack mapping."), + milestone: disabled_capability("Milestones unavailable from YouTrack mapping."), + composer: IssueComposerCapabilities { + enabled: true, + modes: vec!["write".to_string(), "preview".to_string()], + supports_quick_actions: false, + }, + time_tracking: IssueTimeTrackingCapabilities { + enabled: true, + supports_quick_actions: false, + }, + }, + } + } + + fn run_command(&self, issue_id: &str, query: &str) -> Result<(), AppError> { + let url = format!("{}/api/commands?fields=id", self.base_url()); + let response = self + .authorized(self.http.post(url)) + .json(&json!({ + "query": query, + "issues": [{ "idReadable": issue_id }], + })) + .send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack command '{}' failed with status {}", + query, + response.status() + ))); + } + Ok(()) + } + + fn base_url(&self) -> &str { + &self.host + } + + fn authorized(&self, req: reqwest::blocking::RequestBuilder) -> reqwest::blocking::RequestBuilder { + req.header("Authorization", format!("Bearer {}", self.token)) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + } +} + +fn to_iso(timestamp_ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(timestamp_ms) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) +} + +fn map_author(author: &YouTrackAuthor) -> IssueActor { + IssueActor { + username: author.login.clone(), + name: author + .full_name + .clone() + .or_else(|| author.login.clone()) + .unwrap_or_else(|| "Unknown".to_string()), + avatar_url: author.avatar_url.clone(), + } +} + +fn disabled_capability(reason: &str) -> IssueMetadataCapability { + IssueMetadataCapability { + enabled: false, + reason: Some(reason.to_string()), + options: vec![], + } +} + +fn resolve_state(fields: Option<&[YouTrackCustomField]>) -> String { + let Some(fields) = fields else { + return "Open".to_string(); + }; + fields + .iter() + .find(|field| field.name.as_deref() == Some("State")) + .and_then(|field| field.value.as_ref()) + .and_then(|value| value.name.clone().or_else(|| value.text.clone())) + .unwrap_or_else(|| "Open".to_string()) } diff --git a/src-tauri/src/services/issues.rs b/src-tauri/src/services/issues.rs index 1e900e9b..d92842df 100644 --- a/src-tauri/src/services/issues.rs +++ b/src-tauri/src/services/issues.rs @@ -2,13 +2,13 @@ use crate::{ db, domain::models::{ CachedIterationRecord, CreateIssueCommentInput, DeleteIssueCommentInput, DeleteIssueInput, - IssueActivityPage, IssueComposerCapabilities, IssueDetailsCapabilities, IssueDetailsSnapshot, - IssueMetadataCapability, IssueReference, IssueTimeTrackingCapabilities, - LoadIssueActivityPageInput, LoadIssueDetailsInput, LoadIssueDetailsResponse, LogIssueTimeInput, - UpdateIssueCommentInput, UpdateIssueMetadataInput, + IssueActivityPage, IssueDetailsSnapshot, IssueReference, LoadIssueActivityPageInput, + LoadIssueDetailsInput, LoadIssueDetailsResponse, LogIssueTimeInput, UpdateIssueCommentInput, + UpdateIssueMetadataInput, }, error::AppError, providers::gitlab::{enrich_and_dedupe_issue_iteration_options, GitLabClient}, + providers::youtrack::YouTrackClient, services::shared, state::AppState, }; @@ -22,6 +22,15 @@ fn load_gitlab_client(state: &AppState) -> Result { GitLabClient::new(&primary.host, &token) } +fn load_youtrack_client(state: &AppState) -> Result { + let connection = shared::open_connection(state)?; + let primary = shared::load_primary_connection(&connection, "youtrack")?; + let token = db::connection::load_provider_token(&connection, "youtrack", &primary.host)? + .ok_or_else(|| AppError::GitLabApi("No token found for primary YouTrack connection.".to_string()))?; + + YouTrackClient::new(&primary.host, &token) +} + fn load_gitlab_client_and_iteration_catalog( state: &AppState, ) -> Result<(GitLabClient, Vec), AppError> { @@ -53,13 +62,17 @@ pub fn load_issue_details( } Ok(response) } - "youtrack" => Ok(LoadIssueDetailsResponse::Full { - snapshot: Box::new(youtrack_limited_snapshot(IssueReference { + "youtrack" => { + let reference = IssueReference { provider: "youtrack".to_string(), issue_id: input.issue_id.clone(), provider_issue_ref: input.issue_id.clone(), - })), - }), + }; + let client = load_youtrack_client(state)?; + Ok(LoadIssueDetailsResponse::Full { + snapshot: Box::new(client.load_issue_details(&reference)?), + }) + } other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -78,7 +91,10 @@ pub fn update_issue_metadata( enrich_and_dedupe_issue_iteration_options(&mut snapshot, &catalog); Ok(snapshot) } - "youtrack" => Ok(youtrack_limited_snapshot(input.reference.clone())), + "youtrack" => { + let client = load_youtrack_client(state)?; + client.update_issue_metadata(input) + } other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -95,9 +111,10 @@ pub fn create_issue_comment( let client = load_gitlab_client(state)?; client.create_issue_note(&input.reference.provider_issue_ref, &input.body) } - "youtrack" => Err(AppError::GitLabApi( - "YouTrack comments are not available in this version yet.".to_string(), - )), + "youtrack" => { + let client = load_youtrack_client(state)?; + client.create_issue_comment(&input.reference.issue_id, &input.body) + } other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -114,9 +131,10 @@ pub fn update_issue_comment( let client = load_gitlab_client(state)?; client.update_issue_note(&input.reference.issue_id, &input.note_id, &input.body) } - "youtrack" => Err(AppError::GitLabApi( - "YouTrack comment edits are not available in this version yet.".to_string(), - )), + "youtrack" => { + let client = load_youtrack_client(state)?; + client.update_issue_comment(&input.reference.issue_id, &input.note_id, &input.body) + } other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -133,9 +151,10 @@ pub fn delete_issue_comment( let client = load_gitlab_client(state)?; client.delete_issue_note(&input.reference.issue_id, &input.note_id) } - "youtrack" => Err(AppError::GitLabApi( - "YouTrack comment deletes are not available in this version yet.".to_string(), - )), + "youtrack" => { + let client = load_youtrack_client(state)?; + client.delete_issue_comment(&input.reference.issue_id, &input.note_id) + } other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -154,9 +173,14 @@ pub fn log_issue_time(state: &AppState, input: &LogIssueTimeInput) -> Result Err(AppError::GitLabApi( - "YouTrack time logging is not available in this version yet.".to_string(), - )), + "youtrack" => { + let client = load_youtrack_client(state)?; + client.log_issue_time( + &input.reference.issue_id, + &input.time_spent, + input.summary.as_deref(), + ) + } other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -189,11 +213,10 @@ pub fn load_issue_activity_page( let client = load_gitlab_client(state)?; client.load_issue_activity_page(&input.reference, input.page, 10) } - "youtrack" => Ok(IssueActivityPage { - items: vec![], - has_next_page: false, - next_page: None, - }), + "youtrack" => { + let client = load_youtrack_client(state)?; + client.load_issue_activity_page(&input.reference, input.page, 10) + } other => Err(AppError::GitLabApi(format!( "Issue provider '{}' is not supported yet.", other @@ -201,54 +224,3 @@ pub fn load_issue_activity_page( } } -fn disabled_capability(reason: &str) -> IssueMetadataCapability { - IssueMetadataCapability { - enabled: false, - reason: Some(reason.to_string()), - options: vec![], - } -} - -fn youtrack_limited_snapshot(reference: IssueReference) -> IssueDetailsSnapshot { - let reason = "This YouTrack issue is connected, but full API parity is still in progress."; - IssueDetailsSnapshot { - key: reference.issue_id.clone(), - title: format!("YouTrack issue {}", reference.issue_id), - state: "open".to_string(), - reference, - author: None, - created_at: None, - updated_at: None, - web_url: None, - total_time_spent: None, - description: Some(reason.to_string()), - status: None, - status_options: None, - labels: vec![], - milestone_title: None, - milestone: None, - iteration: None, - linked_items: Some(vec![]), - child_items: Some(vec![]), - activity: vec![], - activity_has_next_page: Some(false), - activity_next_page: None, - viewer_username: None, - issue_etag: None, - capabilities: IssueDetailsCapabilities { - status: disabled_capability(reason), - labels: disabled_capability(reason), - iteration: disabled_capability(reason), - milestone: disabled_capability(reason), - composer: IssueComposerCapabilities { - enabled: false, - modes: vec!["write".to_string()], - supports_quick_actions: false, - }, - time_tracking: IssueTimeTrackingCapabilities { - enabled: false, - supports_quick_actions: false, - }, - }, - } -} diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 3e43ef86..92138ec0 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -8,7 +8,7 @@ use crate::{ domain::models::AssignedIssueRecord, domain::models::SyncResult, error::AppError, - providers::gitlab::GitLabClient, + providers::{gitlab::GitLabClient, youtrack::YouTrackClient}, services::{localization, preferences, shared}, state::AppState, support::time::utc_timestamp, @@ -297,7 +297,12 @@ pub fn sync_providers( } if has_youtrack { - on_progress("YouTrack sync not fully implemented yet; keeping connection active.".to_string()); + on_progress("Starting YouTrack sync...".to_string()); + let result = sync_youtrack(state, on_progress)?; + totals.projects_synced += result.projects_synced; + totals.entries_synced += result.entries_synced; + totals.issues_synced += result.issues_synced; + totals.assigned_issues_synced += result.assigned_issues_synced; } if !has_gitlab && !has_youtrack { @@ -309,6 +314,46 @@ pub fn sync_providers( Ok(totals) } +fn sync_youtrack( + state: &AppState, + on_progress: &mut dyn FnMut(String), +) -> Result { + let connection = shared::open_connection(state)?; + let primary = shared::load_primary_connection(&connection, "youtrack")?; + let token = db::connection::load_provider_token(&connection, "youtrack", &primary.host)? + .ok_or_else(|| AppError::GitLabApi("No token found for primary YouTrack connection.".to_string()))?; + let client = YouTrackClient::new(&primary.host, &token)?; + + let records = client.fetch_open_assigned_issues()?; + on_progress(format!("YouTrack: fetched {} assigned issues.", records.len())); + + let tx = connection.unchecked_transaction()?; + let mut count = 0u32; + for record in &records { + db::sync::upsert_assigned_issue(&tx, primary.id, record, AssignedIssueBucket::Open)?; + count += 1; + } + db::sync::clear_missing_assigned_issues_for_buckets( + &tx, + primary.id, + &[AssignedIssueBucket::Open], + &records + .iter() + .map(|item| item.provider_item_id.clone()) + .collect::>(), + )?; + let synced_at = utc_timestamp(); + db::sync::update_provider_last_sync_at(&tx, primary.id, &synced_at)?; + tx.commit()?; + + Ok(SyncResult { + projects_synced: 0, + entries_synced: 0, + issues_synced: count, + assigned_issues_synced: count, + }) +} + struct AssignedIssueSyncSummary { assigned_issues_synced: u32, active_group_ids: Vec, From 24f88c2d96a98a3b73cc101d5e9e743182b8fdf8 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 11:55:34 -0400 Subject: [PATCH 03/44] feat(youtrack): enhance YouTrack client with recent and all closed issues fetching - Added methods to fetch recent closed assigned issues and all closed assigned issues, improving issue management capabilities. - Updated the sync function to incorporate fetching and syncing of recent and all closed issues, enhancing data handling. - Refactored existing code to streamline the process of managing open and closed issues, ensuring better integration with the YouTrack API. - Improved error handling for API interactions related to fetching closed issues, providing clearer feedback on failures. --- src-tauri/src/providers/youtrack.rs | 194 ++++++++++++++++++---------- src-tauri/src/services/sync.rs | 39 +++++- 2 files changed, 161 insertions(+), 72 deletions(-) diff --git a/src-tauri/src/providers/youtrack.rs b/src-tauri/src/providers/youtrack.rs index d6b08686..df772624 100644 --- a/src-tauri/src/providers/youtrack.rs +++ b/src-tauri/src/providers/youtrack.rs @@ -91,9 +91,14 @@ impl YouTrackClient { if token.trim().is_empty() { return Err(AppError::GitLabApi("YouTrack token is required".to_string())); } + let base_url = if host.starts_with("http://") || host.starts_with("https://") { + host.trim_end_matches('/').to_string() + } else { + format!("https://{}", host.trim_end_matches('/')) + }; let http = Client::builder().build()?; Ok(Self { - host: host.trim().trim_end_matches('/').to_string(), + host: base_url, token: token.to_string(), http, }) @@ -108,10 +113,7 @@ impl YouTrackClient { avatar_url: Option, } - let url = format!( - "https://{}/api/users/me?fields=login,fullName,avatarUrl", - self.host - ); + let url = format!("{}/api/users/me?fields=login,fullName,avatarUrl", self.base_url()); let response = self .http .get(url) @@ -162,33 +164,44 @@ impl YouTrackClient { comment_id: &str, body: &str, ) -> Result<(), AppError> { - let url = format!( + let update_url = format!( "{}/api/issues/{issue_id}/comments/{comment_id}?fields=id", self.base_url() ); let response = self - .authorized(self.http.post(url)) + .authorized(self.http.post(update_url.clone())) .json(&json!({ "text": body })) .send()?; - if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( - "YouTrack update comment failed with status {}", - response.status() - ))); + if response.status().is_success() { + return Ok(()); } - Ok(()) + + // Some YouTrack deployments accept PUT for updates. + let retry = self + .authorized(self.http.put(update_url)) + .json(&json!({ "text": body })) + .send()?; + if retry.status().is_success() { + return Ok(()); + } + + Err(AppError::GitLabApi(format!( + "YouTrack update comment failed with statuses {} / {}", + response.status(), + retry.status() + ))) } pub fn delete_issue_comment(&self, issue_id: &str, comment_id: &str) -> Result<(), AppError> { let url = format!("{}/api/issues/{issue_id}/comments/{comment_id}", self.base_url()); let response = self.authorized(self.http.delete(url)).send()?; - if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( - "YouTrack delete comment failed with status {}", - response.status() - ))); + if response.status().is_success() || response.status().as_u16() == 404 { + return Ok(()); } - Ok(()) + Err(AppError::GitLabApi(format!( + "YouTrack delete comment failed with status {}", + response.status() + ))) } pub fn load_issue_activity_page( @@ -306,32 +319,43 @@ impl YouTrackClient { ))); } let issues = response.json::>()?; - Ok(issues - .into_iter() - .map(|issue| AssignedIssueRecord { - issue_graphql_id: issue.id.clone(), - provider_item_id: issue.id_readable.clone(), - title: issue.summary.unwrap_or_else(|| issue.id_readable.clone()), - state: resolve_state(issue.custom_fields.as_deref()), - closed_at: issue.resolved.map(to_iso), - updated_at: issue.updated.map(to_iso), - web_url: Some(format!("{}/issue/{}", self.base_url(), issue.id_readable)), - labels: issue - .tags - .unwrap_or_default() - .into_iter() - .map(|tag| tag.name) - .collect(), - milestone_title: None, - iteration_gitlab_id: None, - iteration_group_id: None, - iteration_cadence_id: None, - iteration_cadence_title: None, - iteration_title: None, - iteration_start_date: None, - iteration_due_date: None, - }) - .collect()) + Ok(issues.into_iter().map(|issue| self.to_assigned_issue_record(issue)).collect()) + } + + pub fn fetch_recent_closed_assigned_issues( + &self, + cutoff_date: &str, + ) -> Result, AppError> { + let url = format!( + "{}/api/issues?query=for:%20me%20resolved:%20{}%20..%20Today&$top=200&fields=id,idReadable,summary,updated,resolved,tags(name),customFields(name,value(name,text))", + self.base_url(), + cutoff_date + ); + let response = self.authorized(self.http.get(url)).send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack recent closed issues failed with status {}", + response.status() + ))); + } + let issues = response.json::>()?; + Ok(issues.into_iter().map(|issue| self.to_assigned_issue_record(issue)).collect()) + } + + pub fn fetch_all_closed_assigned_issues(&self) -> Result, AppError> { + let url = format!( + "{}/api/issues?query=for:%20me%20resolved:%20*%20sort%20by:%20updated%20desc&$top=500&fields=id,idReadable,summary,updated,resolved,tags(name),customFields(name,value(name,text))", + self.base_url() + ); + let response = self.authorized(self.http.get(url)).send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack all closed issues failed with status {}", + response.status() + ))); + } + let issues = response.json::>()?; + Ok(issues.into_iter().map(|issue| self.to_assigned_issue_record(issue)).collect()) } fn fetch_issue(&self, issue_id: &str) -> Result { @@ -381,26 +405,7 @@ impl YouTrackClient { }) .collect::>(); - let status_options = vec![ - IssueStatusOption { - id: "Open".to_string(), - label: "Open".to_string(), - color: None, - icon: None, - }, - IssueStatusOption { - id: "In Progress".to_string(), - label: "In Progress".to_string(), - color: None, - icon: None, - }, - IssueStatusOption { - id: "Done".to_string(), - label: "Done".to_string(), - color: None, - icon: None, - }, - ]; + let status_options = build_status_options(issue.custom_fields.as_deref()); IssueDetailsSnapshot { reference: reference.clone(), @@ -419,7 +424,7 @@ impl YouTrackClient { color: None, icon: None, }), - status_options: Some(status_options), + status_options: Some(status_options.clone()), labels, milestone_title: None, milestone: None, @@ -435,7 +440,15 @@ impl YouTrackClient { status: IssueMetadataCapability { enabled: true, reason: None, - options: vec![], + options: status_options + .iter() + .map(|status| IssueMetadataOption { + id: status.id.clone(), + label: status.label.clone(), + color: status.color.clone(), + badge: None, + }) + .collect(), }, labels: IssueMetadataCapability { enabled: false, @@ -480,6 +493,32 @@ impl YouTrackClient { &self.host } + fn to_assigned_issue_record(&self, issue: YouTrackIssue) -> AssignedIssueRecord { + AssignedIssueRecord { + issue_graphql_id: issue.id.clone(), + provider_item_id: issue.id_readable.clone(), + title: issue.summary.unwrap_or_else(|| issue.id_readable.clone()), + state: resolve_state(issue.custom_fields.as_deref()), + closed_at: issue.resolved.map(to_iso), + updated_at: issue.updated.map(to_iso), + web_url: Some(format!("{}/issue/{}", self.base_url(), issue.id_readable)), + labels: issue + .tags + .unwrap_or_default() + .into_iter() + .map(|tag| tag.name) + .collect(), + milestone_title: None, + iteration_gitlab_id: None, + iteration_group_id: None, + iteration_cadence_id: None, + iteration_cadence_title: None, + iteration_title: None, + iteration_start_date: None, + iteration_due_date: None, + } + } + fn authorized(&self, req: reqwest::blocking::RequestBuilder) -> reqwest::blocking::RequestBuilder { req.header("Authorization", format!("Bearer {}", self.token)) .header("Accept", "application/json") @@ -524,3 +563,24 @@ fn resolve_state(fields: Option<&[YouTrackCustomField]>) -> String { .and_then(|value| value.name.clone().or_else(|| value.text.clone())) .unwrap_or_else(|| "Open".to_string()) } + +fn build_status_options(fields: Option<&[YouTrackCustomField]>) -> Vec { + let current = resolve_state(fields); + let mut base = vec![ + "Open".to_string(), + "In Progress".to_string(), + "Done".to_string(), + "Fixed".to_string(), + ]; + if !base.iter().any(|item| item.eq_ignore_ascii_case(¤t)) { + base.push(current); + } + base.into_iter() + .map(|label| IssueStatusOption { + id: label.clone(), + label, + color: None, + icon: None, + }) + .collect() +} diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 92138ec0..0fe796c6 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -318,27 +318,56 @@ fn sync_youtrack( state: &AppState, on_progress: &mut dyn FnMut(String), ) -> Result { + const RECENT_CLOSED_DAYS: i64 = 180; let connection = shared::open_connection(state)?; let primary = shared::load_primary_connection(&connection, "youtrack")?; let token = db::connection::load_provider_token(&connection, "youtrack", &primary.host)? .ok_or_else(|| AppError::GitLabApi("No token found for primary YouTrack connection.".to_string()))?; let client = YouTrackClient::new(&primary.host, &token)?; - let records = client.fetch_open_assigned_issues()?; - on_progress(format!("YouTrack: fetched {} assigned issues.", records.len())); + let open_records = client.fetch_open_assigned_issues()?; + let today = Utc::now().date_naive(); + let cutoff = today + .checked_sub_days(Days::new(RECENT_CLOSED_DAYS as u64)) + .unwrap_or(today) + .format("%Y-%m-%d") + .to_string(); + let recent_closed_records = client.fetch_recent_closed_assigned_issues(&cutoff)?; + let all_closed_records = client.fetch_all_closed_assigned_issues()?; + on_progress(format!( + "YouTrack: open {}, recent closed {}, all closed {}.", + open_records.len(), + recent_closed_records.len(), + all_closed_records.len() + )); let tx = connection.unchecked_transaction()?; let mut count = 0u32; - for record in &records { + for record in &open_records { db::sync::upsert_assigned_issue(&tx, primary.id, record, AssignedIssueBucket::Open)?; count += 1; } + for record in &recent_closed_records { + db::sync::upsert_assigned_issue(&tx, primary.id, record, AssignedIssueBucket::RecentClosed)?; + count += 1; + } + for record in &all_closed_records { + db::sync::upsert_assigned_issue( + &tx, + primary.id, + record, + bucket_for_closed_issue(record, &format!("{cutoff}T00:00:00Z")), + )?; + count += 1; + } db::sync::clear_missing_assigned_issues_for_buckets( &tx, primary.id, - &[AssignedIssueBucket::Open], - &records + &[AssignedIssueBucket::Open, AssignedIssueBucket::RecentClosed, AssignedIssueBucket::ArchiveClosed], + &open_records .iter() + .chain(recent_closed_records.iter()) + .chain(all_closed_records.iter()) .map(|item| item.provider_item_id.clone()) .collect::>(), )?; From 883e4c3380f50db19a5ccd8db69f2394c6f98fc1 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 11:57:07 -0400 Subject: [PATCH 04/44] feat(youtrack): add work item fetching and syncing capabilities - Introduced a new `YouTrackWorkItem` struct to represent work items fetched from YouTrack. - Implemented `fetch_issue_work_items` method in `YouTrackClient` to retrieve work items associated with specific issues. - Enhanced the `sync_youtrack` function to sync work items for both open and recently closed issues, improving data consistency. - Added utility functions for date formatting to streamline work item data processing. - Updated synchronization logic to track the number of entries and issues synced, providing better feedback on the sync process. --- src-tauri/src/providers/youtrack.rs | 70 +++++++++++++++++++++++++++++ src-tauri/src/services/sync.rs | 45 ++++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/providers/youtrack.rs b/src-tauri/src/providers/youtrack.rs index df772624..986fa794 100644 --- a/src-tauri/src/providers/youtrack.rs +++ b/src-tauri/src/providers/youtrack.rs @@ -26,6 +26,14 @@ pub struct YouTrackUser { pub avatar_url: Option, } +#[derive(Clone, Debug)] +pub struct YouTrackWorkItem { + pub id: String, + pub spent_at: String, + pub uploaded_at: Option, + pub duration_minutes: i64, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct YouTrackIssue { @@ -83,6 +91,21 @@ struct YouTrackCreatedItem { id: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackWorkItemJson { + id: String, + date: Option, + created: Option, + duration: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct YouTrackDuration { + minutes: Option, +} + impl YouTrackClient { pub fn new(host: &str, token: &str) -> Result { if host.trim().is_empty() { @@ -358,6 +381,47 @@ impl YouTrackClient { Ok(issues.into_iter().map(|issue| self.to_assigned_issue_record(issue)).collect()) } + pub fn fetch_issue_work_items( + &self, + issue_id: &str, + top: u32, + ) -> Result, AppError> { + let url = format!( + "{}/api/issues/{}/timeTracking/workItems?$top={}&fields=id,date,created,duration(minutes)", + self.base_url(), + issue_id, + top + ); + let response = self.authorized(self.http.get(url)).send()?; + if !response.status().is_success() { + return Err(AppError::GitLabApi(format!( + "YouTrack work items fetch failed with status {}", + response.status() + ))); + } + + let rows = response.json::>()?; + Ok(rows + .into_iter() + .filter_map(|row| { + let minutes = row.duration.and_then(|d| d.minutes).unwrap_or(0); + if minutes <= 0 { + return None; + } + let spent_at = row + .date + .map(to_iso_date) + .unwrap_or_else(|| chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string()); + Some(YouTrackWorkItem { + id: row.id, + spent_at, + uploaded_at: row.created.map(to_iso), + duration_minutes: minutes, + }) + }) + .collect()) + } + fn fetch_issue(&self, issue_id: &str) -> Result { let url = format!( "{}/api/issues/{issue_id}?fields=id,idReadable,summary,description,created,updated,resolved,tags(name),comments(id,text,created,updated,author(login,fullName,avatarUrl)),customFields(name,value(name,text))", @@ -532,6 +596,12 @@ fn to_iso(timestamp_ms: i64) -> String { .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) } +fn to_iso_date(timestamp_ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(timestamp_ms) + .map(|dt| dt.date_naive().format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string()) +} + fn map_author(author: &YouTrackAuthor) -> IssueActor { IssueActor { username: author.login.clone(), diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 0fe796c6..0a782a73 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -343,6 +343,8 @@ fn sync_youtrack( let tx = connection.unchecked_transaction()?; let mut count = 0u32; + let mut entries_synced = 0u32; + let mut issue_rows_synced = 0u32; for record in &open_records { db::sync::upsert_assigned_issue(&tx, primary.id, record, AssignedIssueBucket::Open)?; count += 1; @@ -360,6 +362,45 @@ fn sync_youtrack( )?; count += 1; } + + // Pull recent work items from issues we already have in assigned buckets. + let mut seen_issue_ids = std::collections::HashSet::new(); + for record in open_records + .iter() + .chain(recent_closed_records.iter()) + .chain(all_closed_records.iter()) + { + if !seen_issue_ids.insert(record.provider_item_id.clone()) { + continue; + } + let labels_json = serde_json::to_string(&record.labels).unwrap_or_else(|_| "[]".to_string()); + let work_item_id = db::sync::upsert_work_item( + &tx, + primary.id, + &record.provider_item_id, + &record.title, + &record.state, + record.web_url.as_deref(), + Some(labels_json.as_str()), + )?; + issue_rows_synced += 1; + + if let Ok(worklogs) = client.fetch_issue_work_items(&record.provider_item_id, 50) { + for worklog in worklogs { + let entry_id = format!("youtrack-{}", worklog.id); + db::sync::upsert_time_entry( + &tx, + primary.id, + &entry_id, + Some(work_item_id), + &worklog.spent_at, + worklog.uploaded_at.as_deref(), + worklog.duration_minutes * 60, + )?; + entries_synced += 1; + } + } + } db::sync::clear_missing_assigned_issues_for_buckets( &tx, primary.id, @@ -377,8 +418,8 @@ fn sync_youtrack( Ok(SyncResult { projects_synced: 0, - entries_synced: 0, - issues_synced: count, + entries_synced, + issues_synced: count + issue_rows_synced, assigned_issues_synced: count, }) } From 382f389eb6ac0dfeb42ee32827dca859e0f7eda3 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 11:58:06 -0400 Subject: [PATCH 05/44] test(youtrack): add unit tests for state resolution and date formatting - Introduced tests for `resolve_state` to ensure it correctly uses the state custom field. - Added tests for `build_status_options` to verify inclusion of current state in options. - Implemented a test for `to_iso_date` to confirm proper date formatting. - Added a test for `YouTrackClient` to ensure the base URL is normalized with the scheme. --- src-tauri/src/providers/youtrack.rs | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src-tauri/src/providers/youtrack.rs b/src-tauri/src/providers/youtrack.rs index 986fa794..7d3c7aec 100644 --- a/src-tauri/src/providers/youtrack.rs +++ b/src-tauri/src/providers/youtrack.rs @@ -654,3 +654,44 @@ fn build_status_options(fields: Option<&[YouTrackCustomField]>) -> Vec YouTrackCustomField { + YouTrackCustomField { + name: Some("State".to_string()), + value: Some(YouTrackFieldValue { + name: Some(name.to_string()), + text: None, + }), + } + } + + #[test] + fn resolve_state_uses_state_custom_field() { + let fields = vec![state_field("In Progress")]; + assert_eq!(resolve_state(Some(&fields)), "In Progress"); + } + + #[test] + fn build_status_options_includes_current_state() { + let fields = vec![state_field("Blocked")]; + let options = build_status_options(Some(&fields)); + assert!(options.iter().any(|option| option.id == "Blocked")); + } + + #[test] + fn to_iso_date_formats_calendar_date() { + // 2026-01-15T00:00:00Z in millis + let date = to_iso_date(1_768_435_200_000); + assert_eq!(date, "2026-01-15"); + } + + #[test] + fn client_normalizes_host_with_scheme() { + let client = YouTrackClient::new("https://company.youtrack.cloud/", "token").unwrap(); + assert_eq!(client.base_url(), "https://company.youtrack.cloud"); + } +} From 4f99aa5908594ea26bd93fc0c223067369366dfc Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 11:59:59 -0400 Subject: [PATCH 06/44] test(sync): add unit tests for issue bucket categorization and string deduplication - Introduced tests for `bucket_for_closed_issue` to verify correct categorization of closed issues based on cutoff dates. - Added a test for `dedupe_strings` to ensure proper trimming, sorting, and removal of empty values. - Implemented a helper function `make_record` to facilitate test data creation for assigned issues. --- src-tauri/src/services/sync.rs | 76 ++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 0a782a73..5f6fca8a 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -755,3 +755,79 @@ fn parse_date(date_str: &str) -> Option { let date_part = date_str.split('T').next()?; NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok() } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_record( + provider_item_id: &str, + closed_at: Option<&str>, + iteration_group_id: Option<&str>, + ) -> AssignedIssueRecord { + AssignedIssueRecord { + issue_graphql_id: format!("gql-{provider_item_id}"), + provider_item_id: provider_item_id.to_string(), + title: format!("Issue {provider_item_id}"), + state: "opened".to_string(), + closed_at: closed_at.map(str::to_string), + updated_at: None, + web_url: None, + labels: vec![], + milestone_title: None, + iteration_gitlab_id: None, + iteration_group_id: iteration_group_id.map(str::to_string), + iteration_cadence_id: None, + iteration_cadence_title: None, + iteration_title: None, + iteration_start_date: None, + iteration_due_date: None, + } + } + + #[test] + fn bucket_for_closed_issue_marks_archive_before_cutoff() { + let record = make_record("YT-1", Some("2025-01-01T00:00:00Z"), None); + let bucket = bucket_for_closed_issue(&record, "2025-06-01T00:00:00Z"); + assert_eq!(bucket, AssignedIssueBucket::ArchiveClosed); + } + + #[test] + fn bucket_for_closed_issue_marks_recent_when_missing_or_after_cutoff() { + let missing = make_record("YT-2", None, None); + let recent = make_record("YT-3", Some("2025-07-01T00:00:00Z"), None); + assert_eq!( + bucket_for_closed_issue(&missing, "2025-06-01T00:00:00Z"), + AssignedIssueBucket::RecentClosed + ); + assert_eq!( + bucket_for_closed_issue(&recent, "2025-06-01T00:00:00Z"), + AssignedIssueBucket::RecentClosed + ); + } + + #[test] + fn dedupe_strings_trims_sorts_and_removes_empty_values() { + let result = dedupe_strings(vec![ + " z ".to_string(), + "".to_string(), + "a".to_string(), + "z".to_string(), + " ".to_string(), + "b".to_string(), + ]); + assert_eq!(result, vec!["a".to_string(), "b".to_string(), "z".to_string()]); + } + + #[test] + fn collect_iteration_group_ids_dedupes_groups() { + let records = vec![ + make_record("YT-4", None, Some("group-2")), + make_record("YT-5", None, Some("group-1")), + make_record("YT-6", None, Some("group-2")), + make_record("YT-7", None, None), + ]; + let groups = collect_iteration_group_ids(&records); + assert_eq!(groups, vec!["group-1".to_string(), "group-2".to_string()]); + } +} From 09c5f2545d8dbaa786f37803f138247aa80c0770 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 12:01:12 -0400 Subject: [PATCH 07/44] refactor(sync): streamline issue synchronization by merging record handling - Introduced a new `merge_youtrack_assigned_records` function to consolidate open, recent closed, and all closed issue records into a single structure, improving synchronization efficiency. - Updated the `sync_youtrack` function to utilize the merged records, reducing code duplication and enhancing clarity. - Added unit tests to verify the prioritization logic in the merging process, ensuring correct categorization of issues based on their status. --- src-tauri/src/services/sync.rs | 85 ++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 5f6fca8a..4fbb6794 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -341,25 +341,20 @@ fn sync_youtrack( all_closed_records.len() )); + let cutoff_timestamp = format!("{cutoff}T00:00:00Z"); + let merged_records = merge_youtrack_assigned_records( + &open_records, + &recent_closed_records, + &all_closed_records, + &cutoff_timestamp, + ); + let tx = connection.unchecked_transaction()?; let mut count = 0u32; let mut entries_synced = 0u32; let mut issue_rows_synced = 0u32; - for record in &open_records { - db::sync::upsert_assigned_issue(&tx, primary.id, record, AssignedIssueBucket::Open)?; - count += 1; - } - for record in &recent_closed_records { - db::sync::upsert_assigned_issue(&tx, primary.id, record, AssignedIssueBucket::RecentClosed)?; - count += 1; - } - for record in &all_closed_records { - db::sync::upsert_assigned_issue( - &tx, - primary.id, - record, - bucket_for_closed_issue(record, &format!("{cutoff}T00:00:00Z")), - )?; + for (record, bucket) in &merged_records { + db::sync::upsert_assigned_issue(&tx, primary.id, record, *bucket)?; count += 1; } @@ -405,11 +400,9 @@ fn sync_youtrack( &tx, primary.id, &[AssignedIssueBucket::Open, AssignedIssueBucket::RecentClosed, AssignedIssueBucket::ArchiveClosed], - &open_records + &merged_records .iter() - .chain(recent_closed_records.iter()) - .chain(all_closed_records.iter()) - .map(|item| item.provider_item_id.clone()) + .map(|(item, _)| item.provider_item_id.clone()) .collect::>(), )?; let synced_at = utc_timestamp(); @@ -424,6 +417,34 @@ fn sync_youtrack( }) } +fn merge_youtrack_assigned_records( + open_records: &[AssignedIssueRecord], + recent_closed_records: &[AssignedIssueRecord], + all_closed_records: &[AssignedIssueRecord], + cutoff_timestamp: &str, +) -> Vec<(AssignedIssueRecord, AssignedIssueBucket)> { + let mut merged = std::collections::BTreeMap::::new(); + + for record in all_closed_records { + let bucket = bucket_for_closed_issue(record, cutoff_timestamp); + merged.insert(record.provider_item_id.clone(), (record.clone(), bucket)); + } + for record in recent_closed_records { + merged.insert( + record.provider_item_id.clone(), + (record.clone(), AssignedIssueBucket::RecentClosed), + ); + } + for record in open_records { + merged.insert( + record.provider_item_id.clone(), + (record.clone(), AssignedIssueBucket::Open), + ); + } + + merged.into_values().collect() +} + struct AssignedIssueSyncSummary { assigned_issues_synced: u32, active_group_ids: Vec, @@ -830,4 +851,30 @@ mod tests { let groups = collect_iteration_group_ids(&records); assert_eq!(groups, vec!["group-1".to_string(), "group-2".to_string()]); } + + #[test] + fn merge_youtrack_assigned_records_prioritizes_open_over_closed() { + let open = vec![make_record("YT-10", None, None)]; + let recent_closed = vec![make_record("YT-10", Some("2025-08-01T00:00:00Z"), None)]; + let all_closed = vec![make_record("YT-10", Some("2025-01-01T00:00:00Z"), None)]; + let merged = + merge_youtrack_assigned_records(&open, &recent_closed, &all_closed, "2025-06-01T00:00:00Z"); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].0.provider_item_id, "YT-10"); + assert_eq!(merged[0].1, AssignedIssueBucket::Open); + } + + #[test] + fn merge_youtrack_assigned_records_keeps_recent_when_not_open() { + let open = vec![]; + let recent_closed = vec![make_record("YT-20", Some("2025-08-01T00:00:00Z"), None)]; + let all_closed = vec![make_record("YT-20", Some("2025-01-01T00:00:00Z"), None)]; + let merged = + merge_youtrack_assigned_records(&open, &recent_closed, &all_closed, "2025-06-01T00:00:00Z"); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].0.provider_item_id, "YT-20"); + assert_eq!(merged[0].1, AssignedIssueBucket::RecentClosed); + } } From 7047ca5d45c73a2b6e3b1c5035ff2d17f42d45cc Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 12:02:21 -0400 Subject: [PATCH 08/44] refactor(sync): improve issue synchronization calculation and add utility function - Updated the `sync_youtrack` function to utilize a new helper function `calc_youtrack_issues_synced`, enhancing clarity and maintainability of the issue synchronization logic. - Added unit tests for `calc_youtrack_issues_synced` to verify correct addition of assigned and work item rows, including saturation handling for overflow cases. --- src-tauri/src/services/sync.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 4fbb6794..3c913b2b 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -412,7 +412,7 @@ fn sync_youtrack( Ok(SyncResult { projects_synced: 0, entries_synced, - issues_synced: count + issue_rows_synced, + issues_synced: calc_youtrack_issues_synced(count, issue_rows_synced), assigned_issues_synced: count, }) } @@ -445,6 +445,10 @@ fn merge_youtrack_assigned_records( merged.into_values().collect() } +fn calc_youtrack_issues_synced(assigned_issue_upserts: u32, work_item_upserts: u32) -> u32 { + assigned_issue_upserts.saturating_add(work_item_upserts) +} + struct AssignedIssueSyncSummary { assigned_issues_synced: u32, active_group_ids: Vec, @@ -877,4 +881,14 @@ mod tests { assert_eq!(merged[0].0.provider_item_id, "YT-20"); assert_eq!(merged[0].1, AssignedIssueBucket::RecentClosed); } + + #[test] + fn calc_youtrack_issues_synced_adds_assigned_and_work_item_rows() { + assert_eq!(calc_youtrack_issues_synced(12, 7), 19); + } + + #[test] + fn calc_youtrack_issues_synced_saturates_on_overflow() { + assert_eq!(calc_youtrack_issues_synced(u32::MAX, 2), u32::MAX); + } } From 072e181e461662511fa403ef7606b3da163408d0 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 12:03:39 -0400 Subject: [PATCH 09/44] refactor(sync): introduce utility function for sync result composition - Added `compose_youtrack_sync_result` function to encapsulate the creation of `SyncResult`, improving code clarity and maintainability. - Updated `sync_youtrack` to utilize the new function, streamlining the synchronization logic. - Added unit tests for `compose_youtrack_sync_result` to ensure consistent field mapping and saturation handling. --- src-tauri/src/services/sync.rs | 39 +++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 3c913b2b..756670fd 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -409,12 +409,11 @@ fn sync_youtrack( db::sync::update_provider_last_sync_at(&tx, primary.id, &synced_at)?; tx.commit()?; - Ok(SyncResult { - projects_synced: 0, + Ok(compose_youtrack_sync_result( + count, + issue_rows_synced, entries_synced, - issues_synced: calc_youtrack_issues_synced(count, issue_rows_synced), - assigned_issues_synced: count, - }) + )) } fn merge_youtrack_assigned_records( @@ -449,6 +448,19 @@ fn calc_youtrack_issues_synced(assigned_issue_upserts: u32, work_item_upserts: u assigned_issue_upserts.saturating_add(work_item_upserts) } +fn compose_youtrack_sync_result( + assigned_issue_upserts: u32, + work_item_upserts: u32, + entries_synced: u32, +) -> SyncResult { + SyncResult { + projects_synced: 0, + entries_synced, + issues_synced: calc_youtrack_issues_synced(assigned_issue_upserts, work_item_upserts), + assigned_issues_synced: assigned_issue_upserts, + } +} + struct AssignedIssueSyncSummary { assigned_issues_synced: u32, active_group_ids: Vec, @@ -891,4 +903,21 @@ mod tests { fn calc_youtrack_issues_synced_saturates_on_overflow() { assert_eq!(calc_youtrack_issues_synced(u32::MAX, 2), u32::MAX); } + + #[test] + fn compose_youtrack_sync_result_maps_fields_consistently() { + let result = compose_youtrack_sync_result(8, 5, 13); + assert_eq!(result.projects_synced, 0); + assert_eq!(result.entries_synced, 13); + assert_eq!(result.issues_synced, 13); + assert_eq!(result.assigned_issues_synced, 8); + } + + #[test] + fn compose_youtrack_sync_result_keeps_issue_count_saturated() { + let result = compose_youtrack_sync_result(u32::MAX, 10, 2); + assert_eq!(result.issues_synced, u32::MAX); + assert_eq!(result.assigned_issues_synced, u32::MAX); + assert_eq!(result.entries_synced, 2); + } } From d30e51988f101e5951cfa2a97017223e2e2be356 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 12:06:05 -0400 Subject: [PATCH 10/44] refactor(sync): simplify provider token checks and enhance error messaging - Replaced direct checks for GitLab and YouTrack tokens with a utility function `has_active_provider_token`, improving code readability and maintainability. - Introduced a new function `no_active_provider_error_message` to centralize error message generation for inactive provider connections. - Added unit tests to verify the functionality of the new utility functions, ensuring consistent behavior across provider checks. --- src-tauri/src/services/sync.rs | 54 ++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 756670fd..2d704bdc 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -280,12 +280,8 @@ pub fn sync_providers( assigned_issues_synced: 0, }; - let has_gitlab = providers - .iter() - .any(|p| p.provider.eq_ignore_ascii_case("gitlab") && p.has_token); - let has_youtrack = providers - .iter() - .any(|p| p.provider.eq_ignore_ascii_case("youtrack") && p.has_token); + let has_gitlab = has_active_provider_token(&providers, "gitlab"); + let has_youtrack = has_active_provider_token(&providers, "youtrack"); if has_gitlab { on_progress("Starting GitLab sync...".to_string()); @@ -306,14 +302,25 @@ pub fn sync_providers( } if !has_gitlab && !has_youtrack { - return Err(AppError::GitLabApi( - "No active provider connections with tokens.".to_string(), - )); + return Err(AppError::GitLabApi(no_active_provider_error_message())); } Ok(totals) } +fn has_active_provider_token( + providers: &[crate::domain::models::ProviderConnection], + provider: &str, +) -> bool { + providers + .iter() + .any(|item| item.provider.eq_ignore_ascii_case(provider) && item.has_token) +} + +fn no_active_provider_error_message() -> String { + "No active provider connections with tokens.".to_string() +} + fn sync_youtrack( state: &AppState, on_progress: &mut dyn FnMut(String), @@ -920,4 +927,33 @@ mod tests { assert_eq!(result.assigned_issues_synced, u32::MAX); assert_eq!(result.entries_synced, 2); } + + #[test] + fn has_active_provider_token_matches_case_insensitive_provider_name() { + let providers = vec![crate::domain::models::ProviderConnection { + id: 1, + provider: "YouTrack".to_string(), + display_name: "YT".to_string(), + host: "yt.local".to_string(), + username: None, + client_id: None, + has_token: true, + state: "live".to_string(), + auth_mode: "PAT".to_string(), + preferred_scope: "api".to_string(), + status_note: "".to_string(), + oauth_ready: true, + is_primary: true, + }]; + assert!(has_active_provider_token(&providers, "youtrack")); + assert!(!has_active_provider_token(&providers, "gitlab")); + } + + #[test] + fn no_active_provider_error_message_is_stable() { + assert_eq!( + no_active_provider_error_message(), + "No active provider connections with tokens.".to_string() + ); + } } From 02a9ba6f6a5799f77115069174c79d1a25e3ecf5 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 12:07:29 -0400 Subject: [PATCH 11/44] refactor(sync): enhance sync progress messaging and introduce utility functions - Replaced hardcoded sync start messages for GitLab and YouTrack with a new utility function `sync_start_message`, improving code maintainability. - Added a new function `youtrack_issue_counts_message` to format YouTrack issue count messages, enhancing clarity in progress reporting. - Included unit tests for both utility functions to ensure consistent output and stability. --- src-tauri/src/services/sync.rs | 41 +++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 2d704bdc..94d1bebe 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -284,7 +284,7 @@ pub fn sync_providers( let has_youtrack = has_active_provider_token(&providers, "youtrack"); if has_gitlab { - on_progress("Starting GitLab sync...".to_string()); + on_progress(sync_start_message("GitLab")); let result = sync_gitlab(state, on_progress)?; totals.projects_synced += result.projects_synced; totals.entries_synced += result.entries_synced; @@ -293,7 +293,7 @@ pub fn sync_providers( } if has_youtrack { - on_progress("Starting YouTrack sync...".to_string()); + on_progress(sync_start_message("YouTrack")); let result = sync_youtrack(state, on_progress)?; totals.projects_synced += result.projects_synced; totals.entries_synced += result.entries_synced; @@ -321,6 +321,10 @@ fn no_active_provider_error_message() -> String { "No active provider connections with tokens.".to_string() } +fn sync_start_message(provider_label: &str) -> String { + format!("Starting {provider_label} sync...") +} + fn sync_youtrack( state: &AppState, on_progress: &mut dyn FnMut(String), @@ -341,11 +345,10 @@ fn sync_youtrack( .to_string(); let recent_closed_records = client.fetch_recent_closed_assigned_issues(&cutoff)?; let all_closed_records = client.fetch_all_closed_assigned_issues()?; - on_progress(format!( - "YouTrack: open {}, recent closed {}, all closed {}.", + on_progress(youtrack_issue_counts_message( open_records.len(), recent_closed_records.len(), - all_closed_records.len() + all_closed_records.len(), )); let cutoff_timestamp = format!("{cutoff}T00:00:00Z"); @@ -468,6 +471,17 @@ fn compose_youtrack_sync_result( } } +fn youtrack_issue_counts_message( + open_count: usize, + recent_closed_count: usize, + all_closed_count: usize, +) -> String { + format!( + "YouTrack: open {}, recent closed {}, all closed {}.", + open_count, recent_closed_count, all_closed_count + ) +} + struct AssignedIssueSyncSummary { assigned_issues_synced: u32, active_group_ids: Vec, @@ -956,4 +970,21 @@ mod tests { "No active provider connections with tokens.".to_string() ); } + + #[test] + fn sync_start_message_is_stable() { + assert_eq!(sync_start_message("GitLab"), "Starting GitLab sync...".to_string()); + assert_eq!( + sync_start_message("YouTrack"), + "Starting YouTrack sync...".to_string() + ); + } + + #[test] + fn youtrack_issue_counts_message_is_stable() { + assert_eq!( + youtrack_issue_counts_message(3, 2, 9), + "YouTrack: open 3, recent closed 2, all closed 9.".to_string() + ); + } } From fb3db9a2897a82c995fbb386c0155be20005218b Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 25 Apr 2026 13:12:37 -0400 Subject: [PATCH 12/44] feat(provider-connection): implement YouTrack and GitLab connection management UI - Introduced `ProviderConnectionRow` component to manage connections for GitLab and YouTrack, enhancing user experience for connecting and disconnecting providers. - Updated `YouTrackAuthPanel` and `GitLabAuthPanel` to integrate with the new connection management UI, allowing for better handling of authentication and connection states. - Enhanced localization support for connection messages and button labels, improving accessibility for users in different languages. - Added unit tests for the new components to ensure functionality and reliability in connection management. --- src-tauri/src/db/bootstrap.rs | 2 +- src-tauri/src/db/connection.rs | 4 +- src-tauri/src/db/sync.rs | 2 +- src-tauri/src/error.rs | 2 + src-tauri/src/providers/mod.rs | 6 +- src-tauri/src/providers/youtrack.rs | 354 ++++++++++++------ src-tauri/src/services/auth.rs | 11 +- src-tauri/src/services/issues.rs | 101 ++++- src-tauri/src/services/shared.rs | 16 +- src-tauri/src/services/sync.rs | 224 +++++++---- src-tauri/src/services/worklog.rs | 4 +- src/app/providers/I18nService/i18n.tsx | 54 ++- .../ProviderConnectionRow.test.tsx | 53 +++ .../ProviderConnectionRow.tsx | 95 +++++ .../YouTrackAuthPanel.test.tsx | 2 +- .../YouTrackAuthPanel/YouTrackAuthPanel.tsx | 77 ++-- .../screens/IssueHubPage/IssueHubPage.tsx | 23 +- .../IssueDetailsMainSection.tsx | 2 +- .../ui/IssueOriginBadge/IssueOriginBadge.tsx | 2 +- .../SettingsConnectionSection.test.tsx | 54 +++ .../SettingsConnectionSection.tsx | 124 ++++-- .../SetupProviderPage.test.tsx | 37 +- .../SetupProviderPage/SetupProviderPage.tsx | 114 ++++-- 23 files changed, 1037 insertions(+), 326 deletions(-) create mode 100644 src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.test.tsx create mode 100644 src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.tsx create mode 100644 src/features/settings/sections/SettingsConnectionSection/SettingsConnectionSection.test.tsx diff --git a/src-tauri/src/db/bootstrap.rs b/src-tauri/src/db/bootstrap.rs index 8138da50..f53a2cce 100644 --- a/src-tauri/src/db/bootstrap.rs +++ b/src-tauri/src/db/bootstrap.rs @@ -730,7 +730,7 @@ pub fn load_assigned_issues_page_from_cache( today: NaiveDate, ) -> Result { if !matches!(input.status.as_str(), "opened" | "closed" | "all") { - return Err(AppError::GitLabApi( + return Err(AppError::ProviderApi( "Assigned issues board supports only opened, closed, or all statuses.".to_string(), )); } diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs index c89df8a0..c52e4046 100644 --- a/src-tauri/src/db/connection.rs +++ b/src-tauri/src/db/connection.rs @@ -282,7 +282,9 @@ pub fn load_gitlab_connections( Ok(rows.collect::, _>>()?) } -pub fn load_provider_connections(connection: &Connection) -> Result, AppError> { +pub fn load_provider_connections( + connection: &Connection, +) -> Result, AppError> { let mut statement = connection.prepare( "SELECT id, provider, display_name, host, oauth_client_id, auth_mode, preferred_scope, oauth_ready, status_note, is_primary, personal_access_token, username FROM provider_accounts diff --git a/src-tauri/src/db/sync.rs b/src-tauri/src/db/sync.rs index c5be3abf..72ced4ce 100644 --- a/src-tauri/src/db/sync.rs +++ b/src-tauri/src/db/sync.rs @@ -469,7 +469,7 @@ pub fn rebuild_daily_buckets_in_tx( }, ) .map_err(|_| { - AppError::GitLabApi( + AppError::ProviderApi( "no default schedule profile found; configure your work schedule first".to_string(), ) })?; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index fabcf776..14f2c604 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -16,6 +16,8 @@ pub enum AppError { Updater(#[from] tauri_plugin_updater::Error), #[error("gitlab api error: {0}")] GitLabApi(String), + #[error("provider api error: {0}")] + ProviderApi(String), #[error("operation timed out: {0}")] Timeout(String), #[error("invalid auth configuration: {0}")] diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 97236222..508a8b2e 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -1,2 +1,6 @@ pub mod gitlab; -pub mod youtrack; +#[path = "youtrack.rs"] +mod youtrack_provider; + +pub use gitlab::GitLabClient; +pub use youtrack_provider::YouTrackClient; diff --git a/src-tauri/src/providers/youtrack.rs b/src-tauri/src/providers/youtrack.rs index 7d3c7aec..92e0874f 100644 --- a/src-tauri/src/providers/youtrack.rs +++ b/src-tauri/src/providers/youtrack.rs @@ -1,13 +1,14 @@ use reqwest::blocking::Client; use serde::Deserialize; use serde_json::json; +use urlencoding::encode; use crate::{ domain::models::{ AssignedIssueRecord, IssueActivityItem, IssueActivityPage, IssueActor, IssueComposerCapabilities, IssueDetailsCapabilities, IssueDetailsSnapshot, - IssueMetadataCapability, IssueMetadataOption, IssueReference, IssueTimeTrackingCapabilities, - IssueStatusOption, UpdateIssueMetadataInput, + IssueMetadataCapability, IssueMetadataOption, IssueReference, IssueStatusOption, + IssueTimeTrackingCapabilities, UpdateIssueMetadataInput, }, error::AppError, }; @@ -109,10 +110,14 @@ struct YouTrackDuration { impl YouTrackClient { pub fn new(host: &str, token: &str) -> Result { if host.trim().is_empty() { - return Err(AppError::GitLabApi("YouTrack host is required".to_string())); + return Err(AppError::ProviderApi( + "YouTrack host is required".to_string(), + )); } if token.trim().is_empty() { - return Err(AppError::GitLabApi("YouTrack token is required".to_string())); + return Err(AppError::ProviderApi( + "YouTrack token is required".to_string(), + )); } let base_url = if host.starts_with("http://") || host.starts_with("https://") { host.trim_end_matches('/').to_string() @@ -127,6 +132,40 @@ impl YouTrackClient { }) } + fn fetch_issues_for_query_paged( + &self, + query: &str, + fields: &str, + ) -> Result, AppError> { + const PAGE: u32 = 100; + const MAX_PAGES: u32 = 100; + let encoded_query = encode(query); + let mut out = Vec::new(); + let mut skip = 0u32; + for _ in 0..MAX_PAGES { + let url = format!( + "{}/api/issues?query={encoded_query}&$top={PAGE}&$skip={skip}&fields={fields}", + self.base_url() + ); + let response = self.authorized(self.http.get(url)).send()?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack issues query failed with status {status}: {body}" + ))); + } + let batch = response.json::>()?; + let batch_len = batch.len(); + out.extend(batch); + if batch_len < PAGE as usize { + break; + } + skip = skip.saturating_add(PAGE); + } + Ok(out) + } + pub fn fetch_user(&self) -> Result { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -136,7 +175,10 @@ impl YouTrackClient { avatar_url: Option, } - let url = format!("{}/api/users/me?fields=login,fullName,avatarUrl", self.base_url()); + let url = format!( + "{}/api/users/me?fields=login,fullName,avatarUrl", + self.base_url() + ); let response = self .http .get(url) @@ -145,9 +187,10 @@ impl YouTrackClient { .send()?; if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( - "YouTrack token validation failed with status {}", - response.status() + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack token validation failed with status {status}: {body}" ))); } @@ -160,19 +203,25 @@ impl YouTrackClient { }) } - pub fn load_issue_details(&self, reference: &IssueReference) -> Result { + pub fn load_issue_details( + &self, + reference: &IssueReference, + ) -> Result { let issue = self.fetch_issue(&reference.issue_id)?; Ok(self.map_issue_details(reference, issue)) } pub fn create_issue_comment(&self, issue_id: &str, body: &str) -> Result { - let url = format!("{}/api/issues/{issue_id}/comments?fields=id", self.base_url()); + let url = format!( + "{}/api/issues/{issue_id}/comments?fields=id", + self.base_url() + ); let response = self .authorized(self.http.post(url)) .json(&json!({ "text": body })) .send()?; if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( + return Err(AppError::ProviderApi(format!( "YouTrack create comment failed with status {}", response.status() ))); @@ -199,29 +248,22 @@ impl YouTrackClient { return Ok(()); } - // Some YouTrack deployments accept PUT for updates. - let retry = self - .authorized(self.http.put(update_url)) - .json(&json!({ "text": body })) - .send()?; - if retry.status().is_success() { - return Ok(()); - } - - Err(AppError::GitLabApi(format!( - "YouTrack update comment failed with statuses {} / {}", - response.status(), - retry.status() + Err(AppError::ProviderApi(format!( + "YouTrack update comment failed with status {}", + response.status() ))) } pub fn delete_issue_comment(&self, issue_id: &str, comment_id: &str) -> Result<(), AppError> { - let url = format!("{}/api/issues/{issue_id}/comments/{comment_id}", self.base_url()); + let url = format!( + "{}/api/issues/{issue_id}/comments/{comment_id}", + self.base_url() + ); let response = self.authorized(self.http.delete(url)).send()?; if response.status().is_success() || response.status().as_u16() == 404 { return Ok(()); } - Err(AppError::GitLabApi(format!( + Err(AppError::ProviderApi(format!( "YouTrack delete comment failed with status {}", response.status() ))) @@ -243,9 +285,10 @@ impl YouTrackClient { ); let response = self.authorized(self.http.get(url)).send()?; if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( - "YouTrack activity load failed with status {}", - response.status() + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack activity load failed with status {status}: {body}" ))); } let comments = response.json::>()?; @@ -282,10 +325,7 @@ impl YouTrackClient { time_spent: &str, summary: Option<&str>, ) -> Result { - let query = match summary { - Some(text) if !text.trim().is_empty() => format!("work {time_spent} {text}"), - _ => format!("work {time_spent}"), - }; + let query = log_time_command(time_spent, summary); let url = format!("{}/api/commands?fields=id", self.base_url()); let response = self .authorized(self.http.post(url)) @@ -295,7 +335,7 @@ impl YouTrackClient { })) .send()?; if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( + return Err(AppError::ProviderApi(format!( "YouTrack log time failed with status {}", response.status() ))); @@ -309,19 +349,40 @@ impl YouTrackClient { ) -> Result { let issue_id = &input.reference.issue_id; if let Some(description) = input.description.as_ref() { - let url = format!("{}/api/issues/{issue_id}?fields=id,idReadable", self.base_url()); + let url = format!( + "{}/api/issues/{issue_id}?fields=id,idReadable", + self.base_url() + ); let response = self .authorized(self.http.post(url)) .json(&json!({ "description": description })) .send()?; if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( + return Err(AppError::ProviderApi(format!( "YouTrack description update failed with status {}", response.status() ))); } } + if let Some(labels) = input.labels.as_ref() { + let tags: Vec<_> = labels.iter().map(|name| json!({ "name": name })).collect(); + let url = format!( + "{}/api/issues/{issue_id}?fields=id,idReadable", + self.base_url() + ); + let response = self + .authorized(self.http.post(url)) + .json(&json!({ "tags": tags })) + .send()?; + if !response.status().is_success() { + return Err(AppError::ProviderApi(format!( + "YouTrack labels update failed with status {}", + response.status() + ))); + } + } + if let Some(state) = input.state.as_ref() { self.run_command(issue_id, &format!("State {state}"))?; } @@ -330,96 +391,94 @@ impl YouTrackClient { } pub fn fetch_open_assigned_issues(&self) -> Result, AppError> { - let url = format!( - "{}/api/issues?query=for:%20me%20%23Unresolved&$top=100&fields=id,idReadable,summary,updated,resolved,tags(name)", - self.base_url() - ); - let response = self.authorized(self.http.get(url)).send()?; - if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( - "YouTrack assigned issues failed with status {}", - response.status() - ))); - } - let issues = response.json::>()?; - Ok(issues.into_iter().map(|issue| self.to_assigned_issue_record(issue)).collect()) + let issues = self.fetch_issues_for_query_paged( + open_assigned_issues_query(), + "id,idReadable,summary,updated,resolved,tags(name)", + )?; + Ok(issues + .into_iter() + .map(|issue| self.to_assigned_issue_record(issue)) + .collect()) } pub fn fetch_recent_closed_assigned_issues( &self, cutoff_date: &str, ) -> Result, AppError> { - let url = format!( - "{}/api/issues?query=for:%20me%20resolved:%20{}%20..%20Today&$top=200&fields=id,idReadable,summary,updated,resolved,tags(name),customFields(name,value(name,text))", - self.base_url(), - cutoff_date - ); - let response = self.authorized(self.http.get(url)).send()?; - if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( - "YouTrack recent closed issues failed with status {}", - response.status() - ))); - } - let issues = response.json::>()?; - Ok(issues.into_iter().map(|issue| self.to_assigned_issue_record(issue)).collect()) + let query = recent_closed_assigned_issues_query(cutoff_date); + let issues = self.fetch_issues_for_query_paged( + &query, + "id,idReadable,summary,updated,resolved,tags(name),customFields(name,value(name,text))", + )?; + Ok(issues + .into_iter() + .map(|issue| self.to_assigned_issue_record(issue)) + .collect()) } pub fn fetch_all_closed_assigned_issues(&self) -> Result, AppError> { - let url = format!( - "{}/api/issues?query=for:%20me%20resolved:%20*%20sort%20by:%20updated%20desc&$top=500&fields=id,idReadable,summary,updated,resolved,tags(name),customFields(name,value(name,text))", - self.base_url() - ); - let response = self.authorized(self.http.get(url)).send()?; - if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( - "YouTrack all closed issues failed with status {}", - response.status() - ))); - } - let issues = response.json::>()?; - Ok(issues.into_iter().map(|issue| self.to_assigned_issue_record(issue)).collect()) + let issues = self.fetch_issues_for_query_paged( + all_closed_assigned_issues_query(), + "id,idReadable,summary,updated,resolved,tags(name),customFields(name,value(name,text))", + )?; + Ok(issues + .into_iter() + .map(|issue| self.to_assigned_issue_record(issue)) + .collect()) } pub fn fetch_issue_work_items( &self, issue_id: &str, - top: u32, ) -> Result, AppError> { - let url = format!( - "{}/api/issues/{}/timeTracking/workItems?$top={}&fields=id,date,created,duration(minutes)", - self.base_url(), - issue_id, - top - ); - let response = self.authorized(self.http.get(url)).send()?; - if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( - "YouTrack work items fetch failed with status {}", - response.status() - ))); - } + const PAGE: u32 = 100; + const MAX_PAGES: u32 = 50; + let mut out = Vec::new(); + let mut skip = 0u32; + for _ in 0..MAX_PAGES { + let url = format!( + "{}/api/issues/{issue_id}/timeTracking/workItems?$top={PAGE}&$skip={skip}&fields=id,date,created,duration(minutes)", + self.base_url() + ); + let response = self.authorized(self.http.get(url)).send()?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack work items fetch failed with status {status}: {body}" + ))); + } - let rows = response.json::>()?; - Ok(rows - .into_iter() - .filter_map(|row| { - let minutes = row.duration.and_then(|d| d.minutes).unwrap_or(0); - if minutes <= 0 { - return None; - } - let spent_at = row - .date - .map(to_iso_date) - .unwrap_or_else(|| chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string()); - Some(YouTrackWorkItem { - id: row.id, - spent_at, - uploaded_at: row.created.map(to_iso), - duration_minutes: minutes, + let rows = response.json::>()?; + let mapped: Vec = rows + .into_iter() + .filter_map(|row| { + let minutes = row.duration.and_then(|d| d.minutes).unwrap_or(0); + if minutes <= 0 { + return None; + } + let spent_at = row.date.map(to_iso_date).unwrap_or_else(|| { + chrono::Utc::now() + .date_naive() + .format("%Y-%m-%d") + .to_string() + }); + Some(YouTrackWorkItem { + id: row.id, + spent_at, + uploaded_at: row.created.map(to_iso), + duration_minutes: minutes, + }) }) - }) - .collect()) + .collect(); + let batch_len = mapped.len(); + out.extend(mapped); + if batch_len < PAGE as usize { + break; + } + skip = skip.saturating_add(PAGE); + } + Ok(out) } fn fetch_issue(&self, issue_id: &str) -> Result { @@ -429,15 +488,20 @@ impl YouTrackClient { ); let response = self.authorized(self.http.get(url)).send()?; if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( - "YouTrack issue load failed with status {}", - response.status() + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(AppError::ProviderApi(format!( + "YouTrack issue load failed with status {status}: {body}" ))); } Ok(response.json::()?) } - fn map_issue_details(&self, reference: &IssueReference, issue: YouTrackIssue) -> IssueDetailsSnapshot { + fn map_issue_details( + &self, + reference: &IssueReference, + issue: YouTrackIssue, + ) -> IssueDetailsSnapshot { let issue_id_for_url = issue.id_readable.clone(); let state = resolve_state(issue.custom_fields.as_deref()); let labels = issue @@ -489,7 +553,7 @@ impl YouTrackClient { icon: None, }), status_options: Some(status_options.clone()), - labels, + labels: labels.clone(), milestone_title: None, milestone: None, iteration: None, @@ -515,9 +579,9 @@ impl YouTrackClient { .collect(), }, labels: IssueMetadataCapability { - enabled: false, - reason: Some("Label updates are not available yet for YouTrack.".to_string()), - options: vec![], + enabled: true, + reason: None, + options: labels, }, iteration: disabled_capability("Iterations unavailable from YouTrack mapping."), milestone: disabled_capability("Milestones unavailable from YouTrack mapping."), @@ -544,7 +608,7 @@ impl YouTrackClient { })) .send()?; if !response.status().is_success() { - return Err(AppError::GitLabApi(format!( + return Err(AppError::ProviderApi(format!( "YouTrack command '{}' failed with status {}", query, response.status() @@ -583,7 +647,10 @@ impl YouTrackClient { } } - fn authorized(&self, req: reqwest::blocking::RequestBuilder) -> reqwest::blocking::RequestBuilder { + fn authorized( + &self, + req: reqwest::blocking::RequestBuilder, + ) -> reqwest::blocking::RequestBuilder { req.header("Authorization", format!("Bearer {}", self.token)) .header("Accept", "application/json") .header("Content-Type", "application/json") @@ -596,10 +663,34 @@ fn to_iso(timestamp_ms: i64) -> String { .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) } +fn open_assigned_issues_query() -> &'static str { + "for: me #Unresolved" +} + +fn recent_closed_assigned_issues_query(cutoff_date: &str) -> String { + format!("for: me #Resolved resolved date: {cutoff_date} .. Today") +} + +fn all_closed_assigned_issues_query() -> &'static str { + "for: me #Resolved sort by: {resolved date} desc" +} + +fn log_time_command(time_spent: &str, summary: Option<&str>) -> String { + match summary { + Some(text) if !text.trim().is_empty() => format!("add work {time_spent} {}", text.trim()), + _ => format!("add work {time_spent}"), + } +} + fn to_iso_date(timestamp_ms: i64) -> String { chrono::DateTime::from_timestamp_millis(timestamp_ms) .map(|dt| dt.date_naive().format("%Y-%m-%d").to_string()) - .unwrap_or_else(|| chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| { + chrono::Utc::now() + .date_naive() + .format("%Y-%m-%d") + .to_string() + }) } fn map_author(author: &YouTrackAuthor) -> IssueActor { @@ -694,4 +785,27 @@ mod tests { let client = YouTrackClient::new("https://company.youtrack.cloud/", "token").unwrap(); assert_eq!(client.base_url(), "https://company.youtrack.cloud"); } + + #[test] + fn assigned_issue_queries_use_youtrack_search_syntax() { + assert_eq!(open_assigned_issues_query(), "for: me #Unresolved"); + assert_eq!( + recent_closed_assigned_issues_query("2026-02-25"), + "for: me #Resolved resolved date: 2026-02-25 .. Today" + ); + assert_eq!( + all_closed_assigned_issues_query(), + "for: me #Resolved sort by: {resolved date} desc" + ); + } + + #[test] + fn log_time_command_uses_official_add_work_syntax() { + assert_eq!(log_time_command("1h", None), "add work 1h"); + assert_eq!( + log_time_command("2h", Some("pairing on provider sync")), + "add work 2h pairing on provider sync" + ); + assert_eq!(log_time_command("30m", Some(" ")), "add work 30m"); + } } diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index 71119fdf..5d629ece 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -2,11 +2,10 @@ use crate::{ auth, db, domain::models::{ AuthLaunchPlan, GitLabConnectionInput, GitLabUserInfo, OAuthCallbackPayload, - OAuthCallbackResolution, ProviderConnection, - ProviderConnectionInput, + OAuthCallbackResolution, ProviderConnection, ProviderConnectionInput, }, error::AppError, - providers::gitlab::GitLabClient, + providers::{GitLabClient, YouTrackClient}, services::shared, state::AppState, }; @@ -119,7 +118,7 @@ pub fn resolve_gitlab_oauth_callback_url( pub fn validate_gitlab_token(state: &AppState, host: &str) -> Result { let connection = shared::open_connection(state)?; let token = db::connection::load_gitlab_token(&connection, host)? - .ok_or_else(|| AppError::GitLabApi("no token found for this host".to_string()))?; + .ok_or_else(|| AppError::ProviderApi("no token found for this host".to_string()))?; let client = GitLabClient::new(host, &token)?; let user = client.fetch_user()?; @@ -141,8 +140,8 @@ pub fn validate_provider_token( if provider.eq_ignore_ascii_case("youtrack") { let connection = shared::open_connection(state)?; let token = db::connection::load_provider_token(&connection, provider, host)? - .ok_or_else(|| AppError::GitLabApi("no token found for this host".to_string()))?; - let client = crate::providers::youtrack::YouTrackClient::new(host, &token)?; + .ok_or_else(|| AppError::ProviderApi("no token found for this host".to_string()))?; + let client = YouTrackClient::new(host, &token)?; let user = client.fetch_user()?; db::connection::update_provider_username(&connection, provider, host, &user.username)?; return Ok(GitLabUserInfo { diff --git a/src-tauri/src/services/issues.rs b/src-tauri/src/services/issues.rs index d92842df..e2c51337 100644 --- a/src-tauri/src/services/issues.rs +++ b/src-tauri/src/services/issues.rs @@ -3,12 +3,12 @@ use crate::{ domain::models::{ CachedIterationRecord, CreateIssueCommentInput, DeleteIssueCommentInput, DeleteIssueInput, IssueActivityPage, IssueDetailsSnapshot, IssueReference, LoadIssueActivityPageInput, - LoadIssueDetailsInput, LoadIssueDetailsResponse, LogIssueTimeInput, UpdateIssueCommentInput, - UpdateIssueMetadataInput, + LoadIssueDetailsInput, LoadIssueDetailsResponse, LogIssueTimeInput, + UpdateIssueCommentInput, UpdateIssueMetadataInput, }, error::AppError, providers::gitlab::{enrich_and_dedupe_issue_iteration_options, GitLabClient}, - providers::youtrack::YouTrackClient, + providers::YouTrackClient, services::shared, state::AppState, }; @@ -16,8 +16,10 @@ use crate::{ fn load_gitlab_client(state: &AppState) -> Result { let connection = shared::open_connection(state)?; let primary = shared::load_primary_gitlab_connection(&connection)?; - let token = db::connection::load_gitlab_token(&connection, &primary.host)? - .ok_or_else(|| AppError::GitLabApi("No token found for primary connection.".to_string()))?; + let token = + db::connection::load_gitlab_token(&connection, &primary.host)?.ok_or_else(|| { + AppError::ProviderApi("No token found for primary connection.".to_string()) + })?; GitLabClient::new(&primary.host, &token) } @@ -26,7 +28,9 @@ fn load_youtrack_client(state: &AppState) -> Result { let connection = shared::open_connection(state)?; let primary = shared::load_primary_connection(&connection, "youtrack")?; let token = db::connection::load_provider_token(&connection, "youtrack", &primary.host)? - .ok_or_else(|| AppError::GitLabApi("No token found for primary YouTrack connection.".to_string()))?; + .ok_or_else(|| { + AppError::ProviderApi("No token found for primary YouTrack connection.".to_string()) + })?; YouTrackClient::new(&primary.host, &token) } @@ -37,8 +41,10 @@ fn load_gitlab_client_and_iteration_catalog( let connection = shared::open_connection(state)?; let primary = shared::load_primary_gitlab_connection(&connection)?; let catalog = db::iteration_catalog::load_rows(&connection, primary.id).unwrap_or_default(); - let token = db::connection::load_gitlab_token(&connection, &primary.host)? - .ok_or_else(|| AppError::GitLabApi("No token found for primary connection.".to_string()))?; + let token = + db::connection::load_gitlab_token(&connection, &primary.host)?.ok_or_else(|| { + AppError::ProviderApi("No token found for primary connection.".to_string()) + })?; Ok((GitLabClient::new(&primary.host, &token)?, catalog)) } @@ -73,7 +79,7 @@ pub fn load_issue_details( snapshot: Box::new(client.load_issue_details(&reference)?), }) } - other => Err(AppError::GitLabApi(format!( + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -95,7 +101,7 @@ pub fn update_issue_metadata( let client = load_youtrack_client(state)?; client.update_issue_metadata(input) } - other => Err(AppError::GitLabApi(format!( + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -115,7 +121,7 @@ pub fn create_issue_comment( let client = load_youtrack_client(state)?; client.create_issue_comment(&input.reference.issue_id, &input.body) } - other => Err(AppError::GitLabApi(format!( + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -135,7 +141,7 @@ pub fn update_issue_comment( let client = load_youtrack_client(state)?; client.update_issue_comment(&input.reference.issue_id, &input.note_id, &input.body) } - other => Err(AppError::GitLabApi(format!( + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -155,7 +161,7 @@ pub fn delete_issue_comment( let client = load_youtrack_client(state)?; client.delete_issue_comment(&input.reference.issue_id, &input.note_id) } - other => Err(AppError::GitLabApi(format!( + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -181,7 +187,7 @@ pub fn log_issue_time(state: &AppState, input: &LogIssueTimeInput) -> Result Err(AppError::GitLabApi(format!( + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -194,10 +200,10 @@ pub fn delete_issue(state: &AppState, input: &DeleteIssueInput) -> Result<(), Ap let client = load_gitlab_client(state)?; client.delete_issue(&input.reference.issue_id) } - "youtrack" => Err(AppError::GitLabApi( + "youtrack" => Err(AppError::ProviderApi( "YouTrack issue delete is not available in this version yet.".to_string(), )), - other => Err(AppError::GitLabApi(format!( + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), @@ -217,10 +223,71 @@ pub fn load_issue_activity_page( let client = load_youtrack_client(state)?; client.load_issue_activity_page(&input.reference, input.page, 10) } - other => Err(AppError::GitLabApi(format!( + other => Err(AppError::ProviderApi(format!( "Issue provider '{}' is not supported yet.", other ))), } } +#[cfg(test)] +mod tests { + use std::{env, path::PathBuf}; + + use rusqlite::Connection; + + use crate::{ + db, + domain::models::{DeleteIssueInput, IssueReference, LoadIssueDetailsInput}, + error::AppError, + state::AppState, + }; + + use super::*; + + fn make_state() -> AppState { + let mut path = env::temp_dir(); + path.push(format!( + "timely-issues-test-{}-{}.sqlite3", + std::process::id(), + rand::random::() + )); + let _ = std::fs::remove_file(&path); + + let connection = Connection::open(&path).unwrap(); + db::migrate(&connection).unwrap(); + drop(connection); + + AppState::new(PathBuf::from(path)) + } + + #[test] + fn load_issue_details_rejects_unknown_provider() { + let state = make_state(); + let input = LoadIssueDetailsInput { + provider: "phantom".to_string(), + issue_id: "X-1".to_string(), + if_none_match: None, + }; + let err = load_issue_details(&state, &input).unwrap_err(); + assert!(matches!(err, AppError::ProviderApi(_))); + let message = err.to_string(); + assert!(message.contains("phantom"), "{message}"); + let _ = std::fs::remove_file(&state.db_path); + } + + #[test] + fn delete_issue_youtrack_is_unsupported() { + let state = make_state(); + let input = DeleteIssueInput { + reference: IssueReference { + provider: "youtrack".to_string(), + issue_id: "DEMO-1".to_string(), + provider_issue_ref: "DEMO-1".to_string(), + }, + }; + let err = delete_issue(&state, &input).unwrap_err(); + assert!(matches!(err, AppError::ProviderApi(_))); + let _ = std::fs::remove_file(&state.db_path); + } +} diff --git a/src-tauri/src/services/shared.rs b/src-tauri/src/services/shared.rs index 4dcf1c16..e39b10cb 100644 --- a/src-tauri/src/services/shared.rs +++ b/src-tauri/src/services/shared.rs @@ -4,8 +4,6 @@ use rusqlite::Connection; use crate::{db, domain::models::ProviderConnection, error::AppError, state::AppState}; -const PRIMARY_GITLAB_CONNECTION_ERROR: &str = "No primary GitLab connection found."; - pub fn open_connection(state: &AppState) -> Result { db::open(&state.db_path) } @@ -13,7 +11,7 @@ pub fn open_connection(state: &AppState) -> Result { pub fn load_primary_gitlab_connection( connection: &Connection, ) -> Result { - load_primary_connection(connection, "GitLab") + load_primary_connection(connection, "gitlab") } pub fn load_primary_connection( @@ -22,8 +20,14 @@ pub fn load_primary_connection( ) -> Result { db::connection::load_provider_connections(connection)? .into_iter() - .find(|connection| connection.is_primary && connection.provider.eq_ignore_ascii_case(provider)) - .ok_or_else(|| AppError::GitLabApi(PRIMARY_GITLAB_CONNECTION_ERROR.to_string())) + .find(|connection| { + connection.is_primary && connection.provider.eq_ignore_ascii_case(provider) + }) + .ok_or_else(|| { + AppError::ProviderApi(format!( + "No primary connection found for provider \"{provider}\"." + )) + }) } pub async fn run_blocking_with_timeout( @@ -42,7 +46,7 @@ where match tokio::time::timeout(timeout, task).await { Ok(join_result) => join_result - .map_err(|error| AppError::GitLabApi(format!("{task_name} task failed: {error}")))?, + .map_err(|error| AppError::ProviderApi(format!("{task_name} task failed: {error}")))?, Err(_) => Err(AppError::Timeout(timeout_message.to_string())), } } diff --git a/src-tauri/src/services/sync.rs b/src-tauri/src/services/sync.rs index 94d1bebe..bc617295 100644 --- a/src-tauri/src/services/sync.rs +++ b/src-tauri/src/services/sync.rs @@ -8,7 +8,7 @@ use crate::{ domain::models::AssignedIssueRecord, domain::models::SyncResult, error::AppError, - providers::{gitlab::GitLabClient, youtrack::YouTrackClient}, + providers::{gitlab::GitLabClient, YouTrackClient}, services::{localization, preferences, shared}, state::AppState, support::time::utc_timestamp, @@ -302,7 +302,7 @@ pub fn sync_providers( } if !has_gitlab && !has_youtrack { - return Err(AppError::GitLabApi(no_active_provider_error_message())); + return Err(AppError::ProviderApi(no_active_provider_error_message())); } Ok(totals) @@ -325,50 +325,51 @@ fn sync_start_message(provider_label: &str) -> String { format!("Starting {provider_label} sync...") } -fn sync_youtrack( +fn load_youtrack_primary_and_client( state: &AppState, - on_progress: &mut dyn FnMut(String), -) -> Result { - const RECENT_CLOSED_DAYS: i64 = 180; +) -> Result< + ( + rusqlite::Connection, + crate::domain::models::ProviderConnection, + YouTrackClient, + ), + AppError, +> { let connection = shared::open_connection(state)?; let primary = shared::load_primary_connection(&connection, "youtrack")?; + db::ensure_gamification_profile(&connection, primary.id)?; let token = db::connection::load_provider_token(&connection, "youtrack", &primary.host)? - .ok_or_else(|| AppError::GitLabApi("No token found for primary YouTrack connection.".to_string()))?; + .ok_or_else(|| { + AppError::ProviderApi("No token found for primary YouTrack connection.".to_string()) + })?; let client = YouTrackClient::new(&primary.host, &token)?; + Ok((connection, primary, client)) +} - let open_records = client.fetch_open_assigned_issues()?; - let today = Utc::now().date_naive(); - let cutoff = today - .checked_sub_days(Days::new(RECENT_CLOSED_DAYS as u64)) - .unwrap_or(today) - .format("%Y-%m-%d") - .to_string(); - let recent_closed_records = client.fetch_recent_closed_assigned_issues(&cutoff)?; - let all_closed_records = client.fetch_all_closed_assigned_issues()?; - on_progress(youtrack_issue_counts_message( - open_records.len(), - recent_closed_records.len(), - all_closed_records.len(), - )); - - let cutoff_timestamp = format!("{cutoff}T00:00:00Z"); - let merged_records = merge_youtrack_assigned_records( - &open_records, - &recent_closed_records, - &all_closed_records, - &cutoff_timestamp, - ); - - let tx = connection.unchecked_transaction()?; +fn sync_youtrack_persist_assigned_issues( + tx: &rusqlite::Transaction<'_>, + provider_account_id: i64, + merged_records: &[(AssignedIssueRecord, AssignedIssueBucket)], +) -> Result { let mut count = 0u32; - let mut entries_synced = 0u32; - let mut issue_rows_synced = 0u32; - for (record, bucket) in &merged_records { - db::sync::upsert_assigned_issue(&tx, primary.id, record, *bucket)?; + for (record, bucket) in merged_records { + db::sync::upsert_assigned_issue(tx, provider_account_id, record, *bucket)?; count += 1; } + Ok(count) +} - // Pull recent work items from issues we already have in assigned buckets. +fn sync_youtrack_work_items_into_time_entries( + tx: &rusqlite::Transaction<'_>, + client: &YouTrackClient, + provider_account_id: i64, + open_records: &[AssignedIssueRecord], + recent_closed_records: &[AssignedIssueRecord], + all_closed_records: &[AssignedIssueRecord], + on_progress: &mut dyn FnMut(String), +) -> Result<(u32, u32), AppError> { + let mut issue_rows_synced = 0u32; + let mut entries_synced = 0u32; let mut seen_issue_ids = std::collections::HashSet::new(); for record in open_records .iter() @@ -378,10 +379,11 @@ fn sync_youtrack( if !seen_issue_ids.insert(record.provider_item_id.clone()) { continue; } - let labels_json = serde_json::to_string(&record.labels).unwrap_or_else(|_| "[]".to_string()); + let labels_json = + serde_json::to_string(&record.labels).unwrap_or_else(|_| "[]".to_string()); let work_item_id = db::sync::upsert_work_item( - &tx, - primary.id, + tx, + provider_account_id, &record.provider_item_id, &record.title, &record.state, @@ -390,38 +392,109 @@ fn sync_youtrack( )?; issue_rows_synced += 1; - if let Ok(worklogs) = client.fetch_issue_work_items(&record.provider_item_id, 50) { - for worklog in worklogs { - let entry_id = format!("youtrack-{}", worklog.id); - db::sync::upsert_time_entry( - &tx, - primary.id, - &entry_id, - Some(work_item_id), - &worklog.spent_at, - worklog.uploaded_at.as_deref(), - worklog.duration_minutes * 60, - )?; - entries_synced += 1; + match client.fetch_issue_work_items(&record.provider_item_id) { + Ok(worklogs) => { + for worklog in worklogs { + let entry_id = format!("youtrack-{}", worklog.id); + db::sync::upsert_time_entry( + tx, + provider_account_id, + &entry_id, + Some(work_item_id), + &worklog.spent_at, + worklog.uploaded_at.as_deref(), + worklog.duration_minutes * 60, + )?; + entries_synced += 1; + } + } + Err(error) => { + on_progress(format!( + "YouTrack: could not load work items for {} ({error})", + record.provider_item_id + )); } } } + Ok((issue_rows_synced, entries_synced)) +} + +fn sync_youtrack( + state: &AppState, + on_progress: &mut dyn FnMut(String), +) -> Result { + const RECENT_CLOSED_DAYS: i64 = 180; + let (connection, primary, client) = load_youtrack_primary_and_client(state)?; + let today = Utc::now().date_naive(); + let start_date = today.checked_sub_months(Months::new(2)).unwrap_or(today); + let end_date = today; + + let open_records = client.fetch_open_assigned_issues()?; + let cutoff = today + .checked_sub_days(Days::new(RECENT_CLOSED_DAYS as u64)) + .unwrap_or(today) + .format("%Y-%m-%d") + .to_string(); + let recent_closed_records = client.fetch_recent_closed_assigned_issues(&cutoff)?; + let all_closed_records = client.fetch_all_closed_assigned_issues()?; + on_progress(youtrack_issue_counts_message( + open_records.len(), + recent_closed_records.len(), + all_closed_records.len(), + )); + + let cutoff_timestamp = format!("{cutoff}T00:00:00Z"); + let merged_records = merge_youtrack_assigned_records( + &open_records, + &recent_closed_records, + &all_closed_records, + &cutoff_timestamp, + ); + + let tx = connection.unchecked_transaction()?; + let assigned_issue_upserts = + sync_youtrack_persist_assigned_issues(&tx, primary.id, &merged_records)?; + + on_progress("YouTrack: refreshing worklog window...".to_string()); + db::sync::delete_time_entries_in_range(&tx, primary.id, &start_date, &end_date)?; + + let (work_item_rows, entries_synced) = sync_youtrack_work_items_into_time_entries( + &tx, + &client, + primary.id, + &open_records, + &recent_closed_records, + &all_closed_records, + on_progress, + )?; + db::sync::clear_missing_assigned_issues_for_buckets( &tx, primary.id, - &[AssignedIssueBucket::Open, AssignedIssueBucket::RecentClosed, AssignedIssueBucket::ArchiveClosed], + &[ + AssignedIssueBucket::Open, + AssignedIssueBucket::RecentClosed, + AssignedIssueBucket::ArchiveClosed, + ], &merged_records .iter() .map(|(item, _)| item.provider_item_id.clone()) .collect::>(), )?; + + db::sync::rebuild_daily_buckets_in_tx(&tx, primary.id, &start_date, &end_date)?; + db::sync::update_quest_progress_from_buckets(&tx, primary.id)?; + + let streak_snapshot = crate::services::streak::build_streak_snapshot(&tx, primary.id, today)?; + crate::services::streak::persist_current_streak(&tx, primary.id, streak_snapshot.current_days)?; + let synced_at = utc_timestamp(); db::sync::update_provider_last_sync_at(&tx, primary.id, &synced_at)?; tx.commit()?; - Ok(compose_youtrack_sync_result( - count, - issue_rows_synced, + Ok(compose_provider_sync_result( + assigned_issue_upserts, + work_item_rows, entries_synced, )) } @@ -432,7 +505,8 @@ fn merge_youtrack_assigned_records( all_closed_records: &[AssignedIssueRecord], cutoff_timestamp: &str, ) -> Vec<(AssignedIssueRecord, AssignedIssueBucket)> { - let mut merged = std::collections::BTreeMap::::new(); + let mut merged = + std::collections::BTreeMap::::new(); for record in all_closed_records { let bucket = bucket_for_closed_issue(record, cutoff_timestamp); @@ -458,7 +532,7 @@ fn calc_youtrack_issues_synced(assigned_issue_upserts: u32, work_item_upserts: u assigned_issue_upserts.saturating_add(work_item_upserts) } -fn compose_youtrack_sync_result( +fn compose_provider_sync_result( assigned_issue_upserts: u32, work_item_upserts: u32, entries_synced: u32, @@ -874,7 +948,10 @@ mod tests { " ".to_string(), "b".to_string(), ]); - assert_eq!(result, vec!["a".to_string(), "b".to_string(), "z".to_string()]); + assert_eq!( + result, + vec!["a".to_string(), "b".to_string(), "z".to_string()] + ); } #[test] @@ -894,8 +971,12 @@ mod tests { let open = vec![make_record("YT-10", None, None)]; let recent_closed = vec![make_record("YT-10", Some("2025-08-01T00:00:00Z"), None)]; let all_closed = vec![make_record("YT-10", Some("2025-01-01T00:00:00Z"), None)]; - let merged = - merge_youtrack_assigned_records(&open, &recent_closed, &all_closed, "2025-06-01T00:00:00Z"); + let merged = merge_youtrack_assigned_records( + &open, + &recent_closed, + &all_closed, + "2025-06-01T00:00:00Z", + ); assert_eq!(merged.len(), 1); assert_eq!(merged[0].0.provider_item_id, "YT-10"); @@ -907,8 +988,12 @@ mod tests { let open = vec![]; let recent_closed = vec![make_record("YT-20", Some("2025-08-01T00:00:00Z"), None)]; let all_closed = vec![make_record("YT-20", Some("2025-01-01T00:00:00Z"), None)]; - let merged = - merge_youtrack_assigned_records(&open, &recent_closed, &all_closed, "2025-06-01T00:00:00Z"); + let merged = merge_youtrack_assigned_records( + &open, + &recent_closed, + &all_closed, + "2025-06-01T00:00:00Z", + ); assert_eq!(merged.len(), 1); assert_eq!(merged[0].0.provider_item_id, "YT-20"); @@ -926,8 +1011,8 @@ mod tests { } #[test] - fn compose_youtrack_sync_result_maps_fields_consistently() { - let result = compose_youtrack_sync_result(8, 5, 13); + fn compose_provider_sync_result_maps_fields_consistently() { + let result = compose_provider_sync_result(8, 5, 13); assert_eq!(result.projects_synced, 0); assert_eq!(result.entries_synced, 13); assert_eq!(result.issues_synced, 13); @@ -935,8 +1020,8 @@ mod tests { } #[test] - fn compose_youtrack_sync_result_keeps_issue_count_saturated() { - let result = compose_youtrack_sync_result(u32::MAX, 10, 2); + fn compose_provider_sync_result_keeps_issue_count_saturated() { + let result = compose_provider_sync_result(u32::MAX, 10, 2); assert_eq!(result.issues_synced, u32::MAX); assert_eq!(result.assigned_issues_synced, u32::MAX); assert_eq!(result.entries_synced, 2); @@ -973,7 +1058,10 @@ mod tests { #[test] fn sync_start_message_is_stable() { - assert_eq!(sync_start_message("GitLab"), "Starting GitLab sync...".to_string()); + assert_eq!( + sync_start_message("GitLab"), + "Starting GitLab sync...".to_string() + ); assert_eq!( sync_start_message("YouTrack"), "Starting YouTrack sync...".to_string() diff --git a/src-tauri/src/services/worklog.rs b/src-tauri/src/services/worklog.rs index 3614f5a7..2aae90fa 100644 --- a/src-tauri/src/services/worklog.rs +++ b/src-tauri/src/services/worklog.rs @@ -60,7 +60,7 @@ pub fn load_worklog_snapshot( localization::format_range_label(start, normalized_end, locale), ) } - _ => return Err(AppError::GitLabApi("Invalid worklog mode".to_string())), + _ => return Err(AppError::ProviderApi("Invalid worklog mode".to_string())), }; let days = load_range_days( @@ -368,7 +368,7 @@ fn parse_issue_tone(json: &str) -> Option { fn parse_date(value: &str) -> Result { NaiveDate::parse_from_str(value, "%Y-%m-%d") - .map_err(|_| AppError::GitLabApi(format!("Invalid date value: {value}"))) + .map_err(|_| AppError::ProviderApi(format!("Invalid date value: {value}"))) } fn start_of_week(today: NaiveDate, week_starts_on: u32) -> NaiveDate { diff --git a/src/app/providers/I18nService/i18n.tsx b/src/app/providers/I18nService/i18n.tsx index 1a70026c..47bb2b5f 100644 --- a/src/app/providers/I18nService/i18n.tsx +++ b/src/app/providers/I18nService/i18n.tsx @@ -330,7 +330,7 @@ const enMessages = { "Toolbar supports headings, lists, code, links, and preview. Paste image URLs in markdown; uploading files uses GitLab in the browser.", "issues.issueCount": ({ count }) => `${count} ${Number(count) === 1 ? "issue" : "issues"}`, "issues.noSprint": "No milestone or iteration", - "issues.openInGitLab": "Open in GitLab", + "issues.openExternalIssue": ({ product }) => `Open in ${product}`, "issues.issueActions": "Issue actions", "issues.deleteIssue": "Delete issue", "issues.issueDeleted": "Issue deleted", @@ -349,7 +349,7 @@ const enMessages = { "issues.submitComment": "Post comment", "issues.closeIssueAction": "Close issue", "issues.reopenIssueAction": "Reopen issue", - "issues.timeLogged": "Time logged on GitLab", + "issues.timeLoggedOnProduct": ({ product }) => `Time logged on ${product}`, "issues.timeLogFailed": "Could not log time", "issues.noteAdded": "Comment posted", "issues.noteFailed": "Could not post comment", @@ -723,6 +723,14 @@ const enMessages = { "providers.callbackValidationFailed": ({ error }) => `Callback validation failed: ${error}`, "providers.oauthPkceFallback": "OAuth PKCE + PAT fallback", "providers.oauthPkce": "OAuth PKCE", + "providers.connect": "Connect", + "providers.connectYouTrack": "Connect YouTrack", + "providers.linkYouTrack": "Link your YouTrack workspace to track work items.", + "providers.youTrackHost": "YouTrack host", + "providers.youTrackToken": "Permanent Token", + "providers.youTrackTokenHint": + "Connect with a permanent token. OAuth is not required for YouTrack.", + "providers.youTrackConnected": "YouTrack connected.", "about.title": "About Timely", "about.subtitle": "Build details for your installed desktop app.", "about.versionLabel": "Version", @@ -757,8 +765,8 @@ const enMessages = { "setup.scheduleStepWeeklyTitle": "Your weekly hours", "setup.scheduleStepWeeklyDescription": "Turn workdays on or off and set start, end, and lunch for each day.", - "setup.providerTitle": "Connect GitLab", - "setup.providerDescription": "Link your account to start tracking time", + "setup.providerTitle": "Connect a Provider", + "setup.providerDescription": "Link your account to start tracking time.", "setup.syncTitle": "Sync your data", "setup.syncDescriptionConnected": "Pulling your worklogs from GitLab", "setup.syncDescriptionDisconnected": "You can sync later from Settings", @@ -1327,7 +1335,7 @@ const esMessages: MessageDictionary = { "issues.issueCount": ({ count }) => `${count} ${Number(count) === 1 ? "incidencia" : "incidencias"}`, "issues.noSprint": "Sin hito ni iteración", - "issues.openInGitLab": "Abrir en GitLab", + "issues.openExternalIssue": ({ product }) => `Abrir en ${product}`, "issues.issueActions": "Acciones de la incidencia", "issues.deleteIssue": "Eliminar incidencia", "issues.issueDeleted": "Incidencia eliminada", @@ -1346,7 +1354,7 @@ const esMessages: MessageDictionary = { "issues.submitComment": "Publicar comentario", "issues.closeIssueAction": "Cerrar incidencia", "issues.reopenIssueAction": "Reabrir incidencia", - "issues.timeLogged": "Tiempo registrado en GitLab", + "issues.timeLoggedOnProduct": ({ product }) => `Tiempo registrado en ${product}`, "issues.timeLogFailed": "No se pudo registrar el tiempo", "issues.noteAdded": "Comentario publicado", "issues.noteFailed": "No se pudo publicar el comentario", @@ -1731,6 +1739,14 @@ const esMessages: MessageDictionary = { "providers.callbackValidationFailed": ({ error }) => `La validación del callback falló: ${error}`, "providers.oauthPkceFallback": "OAuth PKCE + respaldo PAT", "providers.oauthPkce": "OAuth PKCE", + "providers.connect": "Conectar", + "providers.connectYouTrack": "Conectar YouTrack", + "providers.linkYouTrack": "Vincula tu espacio de YouTrack para registrar elementos de trabajo.", + "providers.youTrackHost": "Host de YouTrack", + "providers.youTrackToken": "Token permanente", + "providers.youTrackTokenHint": + "Conéctate con un token permanente. OAuth no es necesario para YouTrack.", + "providers.youTrackConnected": "YouTrack conectado.", "about.title": "Acerca de Timely", "about.subtitle": "Información de esta app de escritorio.", "about.versionLabel": "Versión", @@ -1765,8 +1781,8 @@ const esMessages: MessageDictionary = { "setup.scheduleStepWeeklyTitle": "Tus horas de la semana", "setup.scheduleStepWeeklyDescription": "Activa o desactiva días laborables y define inicio, fin y almuerzo para cada día.", - "setup.providerTitle": "Conectar GitLab", - "setup.providerDescription": "Vincula tu cuenta para empezar a registrar tiempo", + "setup.providerTitle": "Conectar un proveedor", + "setup.providerDescription": "Vincula tu cuenta para empezar a registrar tiempo.", "setup.syncTitle": "Sincroniza tus datos", "setup.syncDescriptionConnected": "Trayendo tus worklogs desde GitLab", "setup.syncDescriptionDisconnected": "Podrás sincronizar más tarde desde Ajustes", @@ -2371,7 +2387,7 @@ const ptMessages: MessageDictionary = { "A barra oferece títulos, listas, código, links e pré-visualização. Para imagens, cole o endereço no texto; para enviar arquivos use o GitLab no navegador.", "issues.issueCount": ({ count }) => `${count} ${Number(count) === 1 ? "item" : "itens"}`, "issues.noSprint": "Sem marco nem iteração", - "issues.openInGitLab": "Abrir no GitLab", + "issues.openExternalIssue": ({ product }) => `Abrir no ${product}`, "issues.issueActions": "Ações do item", "issues.deleteIssue": "Excluir item", "issues.issueDeleted": "Item excluído", @@ -2390,7 +2406,7 @@ const ptMessages: MessageDictionary = { "issues.submitComment": "Publicar comentário", "issues.closeIssueAction": "Fechar item", "issues.reopenIssueAction": "Reabrir item", - "issues.timeLogged": "Tempo registrado no GitLab", + "issues.timeLoggedOnProduct": ({ product }) => `Tempo registrado no ${product}`, "issues.timeLogFailed": "Não foi possível registrar o tempo", "issues.noteAdded": "Comentário publicado", "issues.noteFailed": "Não foi possível publicar o comentário", @@ -2777,6 +2793,14 @@ const ptMessages: MessageDictionary = { "providers.callbackValidationFailed": ({ error }) => `Falha na validação do callback: ${error}`, "providers.oauthPkceFallback": "OAuth PKCE + fallback PAT", "providers.oauthPkce": "OAuth PKCE", + "providers.connect": "Conectar", + "providers.connectYouTrack": "Conectar YouTrack", + "providers.linkYouTrack": "Conecte seu espaço YouTrack para registrar itens de trabalho.", + "providers.youTrackHost": "Host do YouTrack", + "providers.youTrackToken": "Token permanente", + "providers.youTrackTokenHint": + "Conecte-se com um token permanente. OAuth não é necessário para YouTrack.", + "providers.youTrackConnected": "YouTrack conectado.", "about.title": "Sobre o Timely", "about.subtitle": "Informações deste app para desktop.", "about.versionLabel": "Versão", @@ -2814,8 +2838,8 @@ const ptMessages: MessageDictionary = { "setup.scheduleStepWeeklyTitle": "Suas horas da semana", "setup.scheduleStepWeeklyDescription": "Ative ou desative dias úteis e defina início, fim e intervalo de almoço para cada dia.", - "setup.providerTitle": "Conectar GitLab", - "setup.providerDescription": "Conecte sua conta para começar a rastrear tempo", + "setup.providerTitle": "Conectar um provedor", + "setup.providerDescription": "Conecte sua conta para começar a registrar tempo.", "setup.syncTitle": "Sincronize seus dados", "setup.syncDescriptionConnected": "Buscando seus worklogs do GitLab", "setup.syncDescriptionDisconnected": "Você pode sincronizar depois em Configurações", @@ -3201,7 +3225,7 @@ const enVoiceOverrides = { "Tour complete. Connect GitLab in Settings and let the real numbers roll in.", "setup.welcomeDescription": "Your playful worklog sidekick. Let’s get your desk sorted.", "setup.scheduleDescription": "Pick your working hours and the days that count.", - "setup.providerDescription": "Connect GitLab so Timely knows where the work lives.", + "setup.providerDescription": "Connect a provider so Timely knows where the work lives.", "setup.syncDescriptionConnected": "Pulling your GitLab worklogs into place.", "setup.syncDescriptionDisconnected": "You can sync later from Settings if you want to keep moving.", @@ -3381,7 +3405,7 @@ const esVoiceOverrides = { "setup.welcomeDescription": "Tu compañero juguetón para el Registro de trabajo. Vamos a dejarte el escritorio en orden.", "setup.scheduleDescription": "Elige tus horas de trabajo y los días que sí cuentan.", - "setup.providerDescription": "Conecta GitLab para que Timely sepa dónde vive el trabajo.", + "setup.providerDescription": "Conecta un proveedor para que Timely sepa dónde vive el trabajo.", "setup.syncDescriptionConnected": "Trayendo tus registros de GitLab a su sitio.", "setup.syncDescriptionDisconnected": "Si quieres seguir ahora, puedes sincronizar más tarde desde Ajustes.", @@ -3558,7 +3582,7 @@ const ptVoiceOverrides = { "setup.welcomeDescription": "Seu companheiro brincalhão para o Registro de trabalho. Vamos deixar sua mesa em ordem.", "setup.scheduleDescription": "Escolha seu horário e os dias que realmente contam.", - "setup.providerDescription": "Conecte o GitLab para o Timely saber onde o trabalho mora.", + "setup.providerDescription": "Conecte um provedor para o Timely saber onde o trabalho mora.", "setup.syncDescriptionConnected": "Trazendo seus registros do GitLab para o lugar certo.", "setup.syncDescriptionDisconnected": "Se quiser seguir agora, você pode sincronizar depois em Configurações.", diff --git a/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.test.tsx b/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.test.tsx new file mode 100644 index 00000000..65760ce0 --- /dev/null +++ b/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import GitlabIcon from "lucide-react/dist/esm/icons/gitlab.js"; +import { I18nProvider } from "@/app/providers/I18nService/i18n"; +import { ProviderConnectionRow } from "@/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow"; + +function renderRow(overrides: Partial[0]> = {}) { + const defaults = { + providerName: "GitLab", + providerIcon: GitlabIcon, + isConnected: false, + isExpanded: false, + onToggle: vi.fn(), + children:
Auth form content
, + ...overrides, + }; + return render( + + + , + ); +} + +describe("ProviderConnectionRow", () => { + it("shows connect button when disconnected and collapsed", () => { + renderRow(); + expect(screen.getByText("Connect")).toBeInTheDocument(); + expect(screen.queryByTestId("auth-form")).not.toBeInTheDocument(); + }); + + it("shows auth form when expanded", () => { + renderRow({ isExpanded: true }); + expect(screen.getByTestId("auth-form")).toBeInTheDocument(); + }); + + it("calls onToggle when connect button clicked", () => { + const onToggle = vi.fn(); + renderRow({ onToggle }); + fireEvent.click(screen.getByText("Connect")); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("shows disconnect button and summary when connected", () => { + const onDisconnect = vi.fn(); + renderRow({ + isConnected: true, + connectionSummary: "Connected to gitlab.com", + onDisconnect, + }); + expect(screen.getByText("Disconnect")).toBeInTheDocument(); + expect(screen.getByText("Connected to gitlab.com")).toBeInTheDocument(); + expect(screen.queryByText("Connect")).not.toBeInTheDocument(); + }); +}); diff --git a/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.tsx b/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.tsx new file mode 100644 index 00000000..e9dfe79b --- /dev/null +++ b/src/domains/gitlab-connection/ui/ProviderConnectionRow/ProviderConnectionRow.tsx @@ -0,0 +1,95 @@ +import ChevronDown from "lucide-react/dist/esm/icons/chevron-down.js"; +import LogOut from "lucide-react/dist/esm/icons/log-out.js"; +import { AnimatePresence, m } from "motion/react"; +import { useI18n } from "@/app/providers/I18nService/i18n"; +import { cn } from "@/shared/lib/utils"; +import { Button } from "@/shared/ui/Button/Button"; + +import type { LucideIcon } from "lucide-react"; + +interface ProviderConnectionRowProps { + providerName: string; + providerIcon: LucideIcon; + isConnected: boolean; + connectionSummary?: string; + isExpanded: boolean; + onToggle: () => void; + onDisconnect?: () => void; + children: React.ReactNode; +} + +export function ProviderConnectionRow({ + providerName, + providerIcon: Icon, + isConnected, + connectionSummary, + isExpanded, + onToggle, + onDisconnect, + children, +}: Readonly) { + const { t } = useI18n(); + + return ( +
+
+
+ +
+ +
+

{providerName}

+ {isConnected && connectionSummary ? ( +

{connectionSummary}

+ ) : null} +
+ + {isConnected ? ( + + ) : ( + + )} +
+ + + {isExpanded && !isConnected ? ( + +
{children}
+
+ ) : null} +
+
+ ); +} diff --git a/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx index 7421bed2..b25ffb14 100644 --- a/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx +++ b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.test.tsx @@ -47,7 +47,7 @@ describe("YouTrackAuthPanel", () => { fireEvent.change(screen.getByPlaceholderText("perm:xxxxxxxx"), { target: { value: "perm:token" }, }); - fireEvent.click(screen.getByRole("button", { name: "Connect YouTrack" })); + fireEvent.click(screen.getByRole("button", { name: /Connect with Token/i })); await waitFor(() => { expect(onSaveConnection).toHaveBeenCalled(); diff --git a/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx index abff5646..dbe2dbd1 100644 --- a/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx +++ b/src/domains/gitlab-connection/ui/YouTrackAuthPanel/YouTrackAuthPanel.tsx @@ -1,6 +1,11 @@ +import Compass from "lucide-react/dist/esm/icons/compass.js"; +import KeyRound from "lucide-react/dist/esm/icons/key-round.js"; +import Loader2 from "lucide-react/dist/esm/icons/loader-circle.js"; import { useState } from "react"; import { useI18n } from "@/app/providers/I18nService/i18n"; import { Button } from "@/shared/ui/Button/Button"; +import { Input } from "@/shared/ui/Input/Input"; +import { Label } from "@/shared/ui/Label/Label"; import type { ProviderConnection, ProviderConnectionInput } from "@/shared/types/dashboard"; @@ -27,7 +32,7 @@ export function YouTrackAuthPanel({ async function handleConnect() { if (host.trim().length === 0 || token.trim().length === 0) { - setStatus(t("settings.tryAgain")); + setStatus(t("providers.hostAndTokenRequired")); return; } setBusy(true); @@ -44,49 +49,71 @@ export function YouTrackAuthPanel({ if (onValidateToken) { await onValidateToken("youtrack", host); } - setStatus("YouTrack connected."); + setStatus(t("providers.youTrackConnected")); setToken(""); } catch (error) { - setStatus(error instanceof Error ? error.message : t("settings.tryAgain")); + setStatus(error instanceof Error ? error.message : t("providers.connectionFailed")); } finally { setBusy(false); } } return ( -
-
-

- Connect with permanent token. OAuth not required. -

+
+
+
+ +
+
+

+ {t("providers.connectYouTrack")} +

+

{t("providers.linkYouTrack")}

+
-