Previously the status enrichment phase (GraphQL work item status fetch)
ran silently — users saw no feedback between "syncing issues" and the
final enrichment summary. For projects with hundreds of issues and
adaptive page-size retries, this felt like a hang.
Changes across three layers:
GraphQL (graphql.rs):
- Extract fetch_issue_statuses_with_progress() accepting an optional
on_page callback invoked after each paginated fetch with the
running count of fetched IIDs
- Original fetch_issue_statuses() preserved as a zero-cost
delegation wrapper (no callback overhead)
Orchestrator (orchestrator.rs):
- Three new ProgressEvent variants: StatusEnrichmentStarted,
StatusEnrichmentPageFetched, StatusEnrichmentWriting
- Wire the page callback through to the new _with_progress fn
CLI (ingest.rs):
- Handle all three new events in the progress callback, updating
both the per-project spinner and the stage bar with live counts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1294 lines
43 KiB
Rust
1294 lines
43 KiB
Rust
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<String>,
|
|
}
|
|
|
|
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<GraphqlQueryResult> {
|
|
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::<u64>() {
|
|
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<ProjectNode>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ProjectNode {
|
|
#[serde(rename = "workItems")]
|
|
work_items: Option<WorkItemConnection>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct WorkItemConnection {
|
|
nodes: Vec<WorkItemNode>,
|
|
#[serde(rename = "pageInfo")]
|
|
page_info: PageInfo,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct WorkItemNode {
|
|
iid: String,
|
|
widgets: Vec<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PageInfo {
|
|
#[serde(rename = "endCursor")]
|
|
end_cursor: Option<String>,
|
|
#[serde(rename = "hasNextPage")]
|
|
has_next_page: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct StatusWidget {
|
|
status: Option<crate::gitlab::types::WorkItemStatus>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum UnsupportedReason {
|
|
GraphqlEndpointMissing,
|
|
AuthForbidden,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct FetchStatusResult {
|
|
pub statuses: std::collections::HashMap<i64, crate::gitlab::types::WorkItemStatus>,
|
|
pub all_fetched_iids: std::collections::HashSet<i64>,
|
|
pub unsupported_reason: Option<UnsupportedReason>,
|
|
pub partial_error_count: usize,
|
|
pub first_partial_error: Option<String>,
|
|
}
|
|
|
|
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<FetchStatusResult> {
|
|
fetch_issue_statuses_with_progress(client, project_path, None).await
|
|
}
|
|
|
|
pub async fn fetch_issue_statuses_with_progress(
|
|
client: &GraphqlClient,
|
|
project_path: &str,
|
|
on_page: Option<&dyn Fn(usize)>,
|
|
) -> crate::core::error::Result<FetchStatusResult> {
|
|
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<String> = None;
|
|
let mut cursor: Option<String> = 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::<i64>() {
|
|
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::<StatusWidget>(widget.clone())
|
|
&& let Some(status) = sw.status
|
|
{
|
|
statuses.insert(iid, status);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(cb) = &on_page {
|
|
cb(all_fetched_iids.len());
|
|
}
|
|
|
|
// 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<serde_json::Value> = 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);
|
|
}
|
|
}
|