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:
Taylor Eernisse
2026-02-11 08:08:53 -05:00
parent 7d40a81512
commit dc49f5209e
6 changed files with 1372 additions and 1 deletions

1
Cargo.lock generated
View File

@@ -1118,6 +1118,7 @@ dependencies = [
"dirs",
"flate2",
"futures",
"httpdate",
"indicatif",
"libc",
"open",

View File

@@ -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"

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,
};

View File

@@ -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
);
}
}
}