feat(gitlab): add GraphQL client with adaptive pagination and work item status types
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 <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1118,6 +1118,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures",
|
||||
"httpdate",
|
||||
"indicatif",
|
||||
"libc",
|
||||
"open",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<GitLabUser> {
|
||||
self.request("/api/v4/user").await
|
||||
}
|
||||
|
||||
1281
src/gitlab/graphql.rs
Normal file
1281
src/gitlab/graphql.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
|
||||
@@ -262,3 +262,86 @@ pub struct GitLabMergeRequest {
|
||||
pub merge_commit_sha: Option<String>,
|
||||
pub squash_commit_sha: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkItemStatus {
|
||||
pub name: String,
|
||||
pub category: Option<String>,
|
||||
pub color: Option<String>,
|
||||
#[serde(rename = "iconName")]
|
||||
pub icon_name: Option<String>,
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user