From dc49f5209ef656629959545eb0f6bd68ac6690b9 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Wed, 11 Feb 2026 08:08:53 -0500 Subject: [PATCH] feat(gitlab): add GraphQL client with adaptive pagination and work item status types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a reusable GraphQL client (`src/gitlab/graphql.rs`) that handles GitLab's GraphQL API with full error handling for auth failures, rate limiting, and partial errors. Key capabilities: - Adaptive page sizing (100 → 50 → 25 → 10) to handle GitLab GraphQL complexity limits without hardcoding a single safe page size - Paginated issue status fetching via the workItems GraphQL query - Graceful detection of unsupported instances (missing GraphQL endpoint or forbidden auth) so ingestion continues without status data - Retry-After header parsing via the `httpdate` crate for rate limit compliance Also adds `WorkItemStatus` type to `gitlab::types` with name, category, color, and icon_name fields (all optional except name) with comprehensive deserialization tests covering all system statuses (TO_DO, IN_PROGRESS, DONE, CANCELED) and edge cases (null category, unknown future values). The `GitLabClient` gains a `graphql_client()` factory method for ergonomic access from the ingestion pipeline. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + Cargo.toml | 1 + src/gitlab/client.rs | 4 + src/gitlab/graphql.rs | 1281 +++++++++++++++++++++++++++++++++++++++++ src/gitlab/mod.rs | 3 +- src/gitlab/types.rs | 83 +++ 6 files changed, 1372 insertions(+), 1 deletion(-) create mode 100644 src/gitlab/graphql.rs diff --git a/Cargo.lock b/Cargo.lock index 28e261f..f2736b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1118,6 +1118,7 @@ dependencies = [ "dirs", "flate2", "futures", + "httpdate", "indicatif", "libc", "open", diff --git a/Cargo.toml b/Cargo.toml index 68e29b2..d3836a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ rand = "0.8" sha2 = "0.10" flate2 = "1" chrono = { version = "0.4", features = ["serde"] } +httpdate = "1" uuid = { version = "1", features = ["v4"] } regex = "1" strsim = "0.11" diff --git a/src/gitlab/client.rs b/src/gitlab/client.rs index e1e3600..2c69f97 100644 --- a/src/gitlab/client.rs +++ b/src/gitlab/client.rs @@ -95,6 +95,10 @@ impl GitLabClient { } } + pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient { + crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token) + } + pub async fn get_current_user(&self) -> Result { self.request("/api/v4/user").await } diff --git a/src/gitlab/graphql.rs b/src/gitlab/graphql.rs new file mode 100644 index 0000000..36ae731 --- /dev/null +++ b/src/gitlab/graphql.rs @@ -0,0 +1,1281 @@ +use reqwest::Client; +use serde::Deserialize; +use serde_json::Value; +use std::time::{Duration, SystemTime}; +use tracing::warn; + +use crate::core::error::LoreError; + +pub struct GraphqlClient { + http: Client, + base_url: String, + token: String, +} + +#[derive(Debug)] +pub struct GraphqlQueryResult { + pub data: Value, + pub had_partial_errors: bool, + pub first_partial_error: Option, +} + +impl GraphqlClient { + pub fn new(base_url: &str, token: &str) -> Self { + let http = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap_or_else(|_| Client::new()); + + Self { + http, + base_url: base_url.trim_end_matches('/').to_string(), + token: token.to_string(), + } + } + + pub async fn query( + &self, + query: &str, + variables: Value, + ) -> crate::core::error::Result { + let url = format!("{}/api/graphql", self.base_url); + + let body = serde_json::json!({ + "query": query, + "variables": variables, + }); + + let response = self + .http + .post(&url) + .header("Authorization", format!("Bearer {}", self.token)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| LoreError::GitLabNetworkError { + base_url: self.base_url.clone(), + source: Some(e), + })?; + + let status = response.status(); + + match status.as_u16() { + 401 | 403 => return Err(LoreError::GitLabAuthFailed), + 404 => { + return Err(LoreError::GitLabNotFound { + resource: "GraphQL endpoint".into(), + }); + } + 429 => { + let retry_after = parse_retry_after(&response); + return Err(LoreError::GitLabRateLimited { retry_after }); + } + s if s >= 400 => { + return Err(LoreError::Other(format!("GraphQL HTTP {status}"))); + } + _ => {} + } + + let json: Value = response + .json() + .await + .map_err(|e| LoreError::Other(format!("Failed to parse GraphQL response: {e}")))?; + + let errors = json.get("errors").and_then(|e| e.as_array()); + let data = json.get("data"); + + let first_error_msg = errors + .and_then(|arr| arr.first()) + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .map(String::from); + + let has_errors = errors.is_some_and(|arr| !arr.is_empty()); + let data_present = data.is_some_and(|d| !d.is_null()); + + match (has_errors, data_present) { + (true, false) => Err(LoreError::Other(format!( + "GraphQL error: {}", + first_error_msg.unwrap_or_else(|| "unknown error".into()) + ))), + (true, true) => Ok(GraphqlQueryResult { + data: data.unwrap().clone(), + had_partial_errors: true, + first_partial_error: first_error_msg, + }), + (false, true) => Ok(GraphqlQueryResult { + data: data.unwrap().clone(), + had_partial_errors: false, + first_partial_error: None, + }), + (false, false) => Err(LoreError::Other( + "GraphQL response missing 'data' field".into(), + )), + } + } +} + +fn parse_retry_after(response: &reqwest::Response) -> u64 { + let header = match response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + { + Some(s) => s, + None => return 60, + }; + + if let Ok(secs) = header.parse::() { + return secs.max(1); + } + + if let Ok(date) = httpdate::parse_http_date(header) + && let Ok(delta) = date.duration_since(SystemTime::now()) + { + return delta.as_secs().max(1); + } + + 60 +} + +// ═══════════════════════════════════════════════════════════════════════ +// Status Fetcher — adaptive-paging GraphQL work-item status retrieval +// ═══════════════════════════════════════════════════════════════════════ + +const ISSUE_STATUS_QUERY: &str = r#" +query($projectPath: ID!, $after: String, $first: Int!) { + project(fullPath: $projectPath) { + workItems(types: [ISSUE], first: $first, after: $after) { + nodes { + iid + widgets { + ... on WorkItemWidgetStatus { + __typename + status { + name + category + color + iconName + } + } + __typename + } + } + pageInfo { + endCursor + hasNextPage + } + } + } +} +"#; + +const PAGE_SIZES: &[u32] = &[100, 50, 25, 10]; + +#[derive(Deserialize)] +struct WorkItemsResponse { + project: Option, +} + +#[derive(Deserialize)] +struct ProjectNode { + #[serde(rename = "workItems")] + work_items: Option, +} + +#[derive(Deserialize)] +struct WorkItemConnection { + nodes: Vec, + #[serde(rename = "pageInfo")] + page_info: PageInfo, +} + +#[derive(Deserialize)] +struct WorkItemNode { + iid: String, + widgets: Vec, +} + +#[derive(Deserialize)] +struct PageInfo { + #[serde(rename = "endCursor")] + end_cursor: Option, + #[serde(rename = "hasNextPage")] + has_next_page: bool, +} + +#[derive(Deserialize)] +struct StatusWidget { + status: Option, +} + +#[derive(Debug, Clone)] +pub enum UnsupportedReason { + GraphqlEndpointMissing, + AuthForbidden, +} + +#[derive(Debug)] +pub struct FetchStatusResult { + pub statuses: std::collections::HashMap, + pub all_fetched_iids: std::collections::HashSet, + pub unsupported_reason: Option, + pub partial_error_count: usize, + pub first_partial_error: Option, +} + +fn is_complexity_or_timeout_error(msg: &str) -> bool { + let lower = msg.to_ascii_lowercase(); + lower.contains("complexity") || lower.contains("timeout") +} + +pub async fn fetch_issue_statuses( + client: &GraphqlClient, + project_path: &str, +) -> crate::core::error::Result { + let mut statuses = std::collections::HashMap::new(); + let mut all_fetched_iids = std::collections::HashSet::new(); + let mut partial_error_count: usize = 0; + let mut first_partial_error: Option = None; + let mut cursor: Option = None; + let mut page_size_idx: usize = 0; + + loop { + let page_size = PAGE_SIZES[page_size_idx]; + let variables = serde_json::json!({ + "projectPath": project_path, + "after": cursor, + "first": page_size, + }); + + let query_result = match client.query(ISSUE_STATUS_QUERY, variables).await { + Ok(r) => r, + Err(LoreError::GitLabNotFound { .. }) => { + warn!( + "GraphQL endpoint not found for {project_path} — status enrichment unavailable" + ); + return Ok(FetchStatusResult { + statuses, + all_fetched_iids, + unsupported_reason: Some(UnsupportedReason::GraphqlEndpointMissing), + partial_error_count, + first_partial_error, + }); + } + Err(LoreError::GitLabAuthFailed) => { + warn!("GraphQL auth forbidden for {project_path} — status enrichment unavailable"); + return Ok(FetchStatusResult { + statuses, + all_fetched_iids, + unsupported_reason: Some(UnsupportedReason::AuthForbidden), + partial_error_count, + first_partial_error, + }); + } + Err(LoreError::Other(msg)) if is_complexity_or_timeout_error(&msg) => { + if page_size_idx + 1 < PAGE_SIZES.len() { + page_size_idx += 1; + warn!( + "GraphQL complexity/timeout error, reducing page size to {}", + PAGE_SIZES[page_size_idx] + ); + continue; + } + return Err(LoreError::Other(msg)); + } + Err(e) => return Err(e), + }; + + // Track partial errors + if query_result.had_partial_errors { + partial_error_count += 1; + if first_partial_error.is_none() { + first_partial_error.clone_from(&query_result.first_partial_error); + } + } + + // Reset page size after success + page_size_idx = 0; + + // Parse response + let response: WorkItemsResponse = serde_json::from_value(query_result.data) + .map_err(|e| LoreError::Other(format!("Failed to parse GraphQL response: {e}")))?; + + let connection = match response.project.and_then(|p| p.work_items) { + Some(c) => c, + None => break, + }; + + for node in &connection.nodes { + let iid = match node.iid.parse::() { + Ok(id) => id, + Err(_) => { + warn!("Skipping non-numeric work item IID: {}", node.iid); + continue; + } + }; + all_fetched_iids.insert(iid); + + for widget in &node.widgets { + if widget.get("__typename").and_then(|t| t.as_str()) == Some("WorkItemWidgetStatus") + && let Ok(sw) = serde_json::from_value::(widget.clone()) + && let Some(status) = sw.status + { + statuses.insert(iid, status); + } + } + } + + // Pagination + if !connection.page_info.has_next_page { + break; + } + + let new_cursor = connection.page_info.end_cursor; + if new_cursor.is_none() || new_cursor == cursor { + warn!( + "Pagination cursor stall detected for {project_path}, aborting with partial results" + ); + break; + } + cursor = new_cursor; + } + + Ok(FetchStatusResult { + statuses, + all_fetched_iids, + unsupported_reason: None, + partial_error_count, + first_partial_error, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::error::LoreError; + use wiremock::matchers::{body_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // ═══════════════════════════════════════════════════════════════════════ + // AC-1: GraphQL Client + // ═══════════════════════════════════════════════════════════════════════ + + #[tokio::test] + async fn test_graphql_query_success() { + let server = MockServer::start().await; + let response_body = serde_json::json!({ + "data": { "project": { "id": "1" } } + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "test-token"); + let result = client + .query("{ project { id } }", serde_json::json!({})) + .await + .unwrap(); + + assert_eq!(result.data["project"]["id"], "1"); + assert!(!result.had_partial_errors); + assert!(result.first_partial_error.is_none()); + } + + #[tokio::test] + async fn test_graphql_query_with_errors_no_data() { + let server = MockServer::start().await; + let response_body = serde_json::json!({ + "errors": [{ "message": "Field 'foo' not found" }] + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "test-token"); + let err = client + .query("{ foo }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::Other(msg) => { + assert!(msg.contains("Field 'foo' not found"), "got: {msg}"); + } + other => panic!("Expected LoreError::Other, got: {other:?}"), + } + } + + #[tokio::test] + async fn test_graphql_auth_uses_bearer() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .and(header("Authorization", "Bearer my-secret-token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "my-secret-token"); + let result = client.query("{ ok }", serde_json::json!({})).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_graphql_401_maps_to_auth_failed() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "bad-token"); + let err = client + .query("{ me }", serde_json::json!({})) + .await + .unwrap_err(); + + assert!(matches!(err, LoreError::GitLabAuthFailed)); + } + + #[tokio::test] + async fn test_graphql_403_maps_to_auth_failed() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "forbidden-token"); + let err = client + .query("{ admin }", serde_json::json!({})) + .await + .unwrap_err(); + + assert!(matches!(err, LoreError::GitLabAuthFailed)); + } + + #[tokio::test] + async fn test_graphql_404_maps_to_not_found() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::GitLabNotFound { resource } => { + assert_eq!(resource, "GraphQL endpoint"); + } + other => panic!("Expected GitLabNotFound, got: {other:?}"), + } + } + + #[tokio::test] + async fn test_graphql_partial_data_with_errors_returns_data() { + let server = MockServer::start().await; + let response_body = serde_json::json!({ + "data": { "project": { "name": "test" } }, + "errors": [{ "message": "Some field failed" }] + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let result = client + .query("{ project { name } }", serde_json::json!({})) + .await + .unwrap(); + + assert_eq!(result.data["project"]["name"], "test"); + assert!(result.had_partial_errors); + assert_eq!( + result.first_partial_error.as_deref(), + Some("Some field failed") + ); + } + + #[tokio::test] + async fn test_retry_after_delta_seconds() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "120")) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::GitLabRateLimited { retry_after } => { + assert_eq!(retry_after, 120); + } + other => panic!("Expected GitLabRateLimited, got: {other:?}"), + } + } + + #[tokio::test] + async fn test_retry_after_http_date_format() { + let server = MockServer::start().await; + + let future = SystemTime::now() + Duration::from_secs(90); + let date_str = httpdate::fmt_http_date(future); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", date_str)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::GitLabRateLimited { retry_after } => { + assert!( + (85..=95).contains(&retry_after), + "retry_after={retry_after}" + ); + } + other => panic!("Expected GitLabRateLimited, got: {other:?}"), + } + } + + #[tokio::test] + async fn test_retry_after_invalid_falls_back_to_60() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "garbage")) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::GitLabRateLimited { retry_after } => { + assert_eq!(retry_after, 60); + } + other => panic!("Expected GitLabRateLimited, got: {other:?}"), + } + } + + #[tokio::test] + async fn test_graphql_network_error() { + let client = GraphqlClient::new("http://127.0.0.1:1", "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + assert!( + matches!(err, LoreError::GitLabNetworkError { .. }), + "Expected GitLabNetworkError, got: {err:?}" + ); + } + + #[tokio::test] + async fn test_graphql_request_body_format() { + let server = MockServer::start().await; + + let expected_body = serde_json::json!({ + "query": "{ project(fullPath: $path) { id } }", + "variables": { "path": "group/repo" } + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .and(body_json(&expected_body)) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"data": {"project": {"id": "1"}}})), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let result = client + .query( + "{ project(fullPath: $path) { id } }", + serde_json::json!({"path": "group/repo"}), + ) + .await; + + assert!(result.is_ok(), "Body format mismatch: {result:?}"); + } + + #[tokio::test] + async fn test_graphql_base_url_trailing_slash() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})), + ) + .mount(&server) + .await; + + let url_with_slash = format!("{}/", server.uri()); + let client = GraphqlClient::new(&url_with_slash, "token"); + let result = client.query("{ ok }", serde_json::json!({})).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_graphql_data_null_no_errors() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": null})), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::Other(msg) => { + assert!(msg.contains("missing 'data' field"), "got: {msg}"); + } + other => panic!("Expected LoreError::Other, got: {other:?}"), + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // AC-3: Status Fetcher + // ═══════════════════════════════════════════════════════════════════════ + + /// Helper: build a GraphQL work-items response page with given issues. + fn make_work_items_page( + items: &[(i64, Option<&str>)], + has_next_page: bool, + end_cursor: Option<&str>, + ) -> serde_json::Value { + let nodes: Vec = items + .iter() + .map(|(iid, status_name)| { + let mut widgets = + vec![serde_json::json!({"__typename": "WorkItemWidgetDescription"})]; + if let Some(name) = status_name { + widgets.push(serde_json::json!({ + "__typename": "WorkItemWidgetStatus", + "status": { + "name": name, + "category": "IN_PROGRESS", + "color": "#1f75cb", + "iconName": "status-in-progress" + } + })); + } + serde_json::json!({ + "iid": iid.to_string(), + "widgets": widgets, + }) + }) + .collect(); + + serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": nodes, + "pageInfo": { + "endCursor": end_cursor, + "hasNextPage": has_next_page, + } + } + } + } + }) + } + + /// Helper: build a page where issue has status widget but status is null. + fn make_null_status_widget_page(iid: i64) -> serde_json::Value { + serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{ + "iid": iid.to_string(), + "widgets": [ + {"__typename": "WorkItemWidgetStatus", "status": null} + ] + }], + "pageInfo": { + "endCursor": null, + "hasNextPage": false, + } + } + } + } + }) + } + + #[tokio::test] + async fn test_fetch_statuses_pagination() { + let server = MockServer::start().await; + + // Page 1: returns cursor "cursor_page2" + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with({ + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(1, Some("In progress")), (2, Some("To do"))], + true, + Some("cursor_page2"), + )) + }) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + + // Page 2: no more pages + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(3, Some("Done"))], + false, + None, + )), + ) + .expect(1) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 3); + assert!(result.statuses.contains_key(&1)); + assert!(result.statuses.contains_key(&2)); + assert!(result.statuses.contains_key(&3)); + assert_eq!(result.all_fetched_iids.len(), 3); + assert!(result.unsupported_reason.is_none()); + } + + #[tokio::test] + async fn test_fetch_statuses_no_status_widget() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{ + "iid": "42", + "widgets": [ + {"__typename": "WorkItemWidgetDescription"}, + {"__typename": "WorkItemWidgetLabels"} + ] + }], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + } + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!(result.statuses.is_empty(), "No status widget → no statuses"); + assert!( + result.all_fetched_iids.contains(&42), + "IID 42 should still be in all_fetched_iids" + ); + } + + #[tokio::test] + async fn test_fetch_statuses_404_graceful() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!(result.statuses.is_empty()); + assert!(result.all_fetched_iids.is_empty()); + assert!(matches!( + result.unsupported_reason, + Some(UnsupportedReason::GraphqlEndpointMissing) + )); + } + + #[tokio::test] + async fn test_fetch_statuses_403_graceful() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!(result.statuses.is_empty()); + assert!(result.all_fetched_iids.is_empty()); + assert!(matches!( + result.unsupported_reason, + Some(UnsupportedReason::AuthForbidden) + )); + } + + #[tokio::test] + async fn test_fetch_statuses_unsupported_reason_none_on_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(1, Some("To do"))], + false, + None, + )), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!(result.unsupported_reason.is_none()); + } + + #[tokio::test] + async fn test_typename_matching_ignores_non_status_widgets() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{ + "iid": "10", + "widgets": [ + {"__typename": "WorkItemWidgetDescription"}, + {"__typename": "WorkItemWidgetLabels"}, + {"__typename": "WorkItemWidgetAssignees"}, + { + "__typename": "WorkItemWidgetStatus", + "status": { + "name": "In progress", + "category": "IN_PROGRESS" + } + } + ] + }], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + } + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 1); + assert_eq!(result.statuses[&10].name, "In progress"); + } + + #[tokio::test] + async fn test_fetch_statuses_cursor_stall_aborts() { + let server = MockServer::start().await; + + let stall_response = serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{"iid": "1", "widgets": []}], + "pageInfo": {"endCursor": "same_cursor", "hasNextPage": true} + } + } + } + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(stall_response)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!( + result.all_fetched_iids.contains(&1), + "Should contain the one IID fetched before stall" + ); + } + + #[tokio::test] + async fn test_fetch_statuses_complexity_error_reduces_page_size() { + let server = MockServer::start().await; + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let call_count_clone = call_count.clone(); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(move |_req: &wiremock::Request| { + let n = + call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{"message": "Query has complexity of 300, which exceeds max complexity of 250"}] + })) + } else { + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(1, Some("In progress"))], + false, + None, + )) + } + }) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 1); + assert_eq!(result.statuses[&1].name, "In progress"); + assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn test_fetch_statuses_timeout_error_reduces_page_size() { + let server = MockServer::start().await; + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let call_count_clone = call_count.clone(); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(move |_req: &wiremock::Request| { + let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{"message": "Query timeout after 30000ms"}] + })) + } else { + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(5, Some("Done"))], + false, + None, + )) + } + }) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 1); + assert!(call_count.load(std::sync::atomic::Ordering::SeqCst) >= 2); + } + + #[tokio::test] + async fn test_fetch_statuses_smallest_page_still_fails() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{"message": "Query has complexity of 9999"}] + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let err = fetch_issue_statuses(&client, "group/project") + .await + .unwrap_err(); + + assert!( + matches!(err, LoreError::Other(_)), + "Expected error after exhausting all page sizes, got: {err:?}" + ); + } + + #[tokio::test] + async fn test_fetch_statuses_page_size_resets_after_success() { + let server = MockServer::start().await; + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let call_count_clone = call_count.clone(); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(move |_req: &wiremock::Request| { + let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match n { + 0 => { + // Page 1 at size 100: success, has next page + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(1, Some("To do"))], + true, + Some("cursor_p2"), + )) + } + 1 => { + // Page 2 at size 100 (reset): complexity error + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{"message": "Query has complexity of 300"}] + })) + } + 2 => { + // Page 2 retry at size 50: success + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(2, Some("Done"))], + false, + None, + )) + } + _ => ResponseTemplate::new(500), + } + }) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 2); + assert!(result.statuses.contains_key(&1)); + assert!(result.statuses.contains_key(&2)); + assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn test_fetch_statuses_partial_errors_tracked() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{"iid": "1", "widgets": [ + {"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}} + ]}], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + }, + "errors": [{"message": "Rate limit warning: approaching limit"}] + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.partial_error_count, 1); + assert_eq!( + result.first_partial_error.as_deref(), + Some("Rate limit warning: approaching limit") + ); + assert_eq!(result.statuses.len(), 1); + } + + #[tokio::test] + async fn test_fetch_statuses_empty_project() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + } + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!(result.statuses.is_empty()); + assert!(result.all_fetched_iids.is_empty()); + assert!(result.unsupported_reason.is_none()); + assert_eq!(result.partial_error_count, 0); + } + + #[tokio::test] + async fn test_fetch_statuses_null_status_in_widget() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(make_null_status_widget_page(42)), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!( + result.statuses.is_empty(), + "Null status should not be in map" + ); + assert!( + result.all_fetched_iids.contains(&42), + "IID should still be tracked in all_fetched_iids" + ); + } + + #[tokio::test] + async fn test_fetch_statuses_non_numeric_iid_skipped() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [ + { + "iid": "not_a_number", + "widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}}] + }, + { + "iid": "7", + "widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "Done"}}] + } + ], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + } + })), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 1); + assert!(result.statuses.contains_key(&7)); + assert_eq!(result.all_fetched_iids.len(), 1); + } + + #[tokio::test] + async fn test_fetch_statuses_null_cursor_with_has_next_aborts() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{"iid": "1", "widgets": []}], + "pageInfo": {"endCursor": null, "hasNextPage": true} + } + } + } + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.all_fetched_iids.len(), 1); + } +} diff --git a/src/gitlab/mod.rs b/src/gitlab/mod.rs index b43b933..9578947 100644 --- a/src/gitlab/mod.rs +++ b/src/gitlab/mod.rs @@ -1,4 +1,5 @@ pub mod client; +pub mod graphql; pub mod transformers; pub mod types; @@ -10,5 +11,5 @@ pub use transformers::{ pub use types::{ GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabIssueRef, GitLabLabelEvent, GitLabLabelRef, GitLabMergeRequestRef, GitLabMilestoneEvent, GitLabMilestoneRef, GitLabMrDiff, GitLabNote, - GitLabNotePosition, GitLabProject, GitLabStateEvent, GitLabUser, GitLabVersion, + GitLabNotePosition, GitLabProject, GitLabStateEvent, GitLabUser, GitLabVersion, WorkItemStatus, }; diff --git a/src/gitlab/types.rs b/src/gitlab/types.rs index 02cc9bb..eb37219 100644 --- a/src/gitlab/types.rs +++ b/src/gitlab/types.rs @@ -262,3 +262,86 @@ pub struct GitLabMergeRequest { pub merge_commit_sha: Option, pub squash_commit_sha: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkItemStatus { + pub name: String, + pub category: Option, + pub color: Option, + #[serde(rename = "iconName")] + pub icon_name: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_work_item_status_deserialize() { + let json = r##"{"name":"In progress","category":"IN_PROGRESS","color":"#1f75cb","iconName":"status-in-progress"}"##; + let status: WorkItemStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status.name, "In progress"); + assert_eq!(status.category.as_deref(), Some("IN_PROGRESS")); + assert_eq!(status.color.as_deref(), Some("#1f75cb")); + assert_eq!(status.icon_name.as_deref(), Some("status-in-progress")); + } + + #[test] + fn test_work_item_status_optional_fields() { + let json = r#"{"name":"To do"}"#; + let status: WorkItemStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status.name, "To do"); + assert!(status.category.is_none()); + assert!(status.color.is_none()); + assert!(status.icon_name.is_none()); + } + + #[test] + fn test_work_item_status_unknown_category() { + let json = r#"{"name":"Custom","category":"SOME_FUTURE_VALUE"}"#; + let status: WorkItemStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status.category.as_deref(), Some("SOME_FUTURE_VALUE")); + } + + #[test] + fn test_work_item_status_null_category() { + let json = r#"{"name":"In progress","category":null}"#; + let status: WorkItemStatus = serde_json::from_str(json).unwrap(); + assert!(status.category.is_none()); + } + + #[test] + fn test_work_item_status_all_system_statuses() { + let cases = [ + ( + r##"{"name":"To do","category":"TO_DO","color":"#737278"}"##, + "TO_DO", + ), + ( + r##"{"name":"In progress","category":"IN_PROGRESS","color":"#1f75cb"}"##, + "IN_PROGRESS", + ), + ( + r##"{"name":"Done","category":"DONE","color":"#108548"}"##, + "DONE", + ), + ( + r##"{"name":"Won't do","category":"CANCELED","color":"#DD2B0E"}"##, + "CANCELED", + ), + ( + r##"{"name":"Duplicate","category":"CANCELED","color":"#DD2B0E"}"##, + "CANCELED", + ), + ]; + for (json, expected_cat) in cases { + let status: WorkItemStatus = serde_json::from_str(json).unwrap(); + assert_eq!( + status.category.as_deref(), + Some(expected_cat), + "Failed for: {}", + status.name + ); + } + } +}