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",
|
"dirs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
|
"httpdate",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
"open",
|
"open",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ rand = "0.8"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
httpdate = "1"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
strsim = "0.11"
|
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> {
|
pub async fn get_current_user(&self) -> Result<GitLabUser> {
|
||||||
self.request("/api/v4/user").await
|
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 client;
|
||||||
|
pub mod graphql;
|
||||||
pub mod transformers;
|
pub mod transformers;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
@@ -10,5 +11,5 @@ pub use transformers::{
|
|||||||
pub use types::{
|
pub use types::{
|
||||||
GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabIssueRef, GitLabLabelEvent, GitLabLabelRef,
|
GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabIssueRef, GitLabLabelEvent, GitLabLabelRef,
|
||||||
GitLabMergeRequestRef, GitLabMilestoneEvent, GitLabMilestoneRef, GitLabMrDiff, GitLabNote,
|
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 merge_commit_sha: Option<String>,
|
||||||
pub squash_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