feat(client): Add Resource Events API endpoints with generic paginated fetcher

Extends GitLabClient with methods for fetching resource events from
GitLab's per-entity API endpoints. Adds a new impl block containing:

- fetch_all_pages<T>: Generic paginated collector that handles
  x-next-page header parsing with fallback to page-size heuristics.
  Uses per_page=100 and respects the existing rate limiter via
  request_with_headers. Terminates when: (a) x-next-page header is
  absent/stale, (b) response is empty, or (c) page is not full.

- Six typed endpoint methods:
  - fetch_issue_state_events / fetch_mr_state_events
  - fetch_issue_label_events / fetch_mr_label_events
  - fetch_issue_milestone_events / fetch_mr_milestone_events

- fetch_all_resource_events: Convenience method that fetches all three
  event types for an entity (issue or merge_request) in sequence,
  returning a tuple of (state, label, milestone) event vectors.
  Routes to issue or MR endpoints based on entity_type string.

All methods follow the existing client patterns: path formatting with
gitlab_project_id and iid, error propagation via Result, and rate
limiter integration through the shared request_with_headers path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-03 12:07:19 -05:00
parent 9d4755521f
commit e73d2907dc

View File

@@ -13,7 +13,8 @@ use tokio::time::sleep;
use tracing::debug;
use super::types::{
GitLabDiscussion, GitLabIssue, GitLabMergeRequest, GitLabProject, GitLabUser, GitLabVersion,
GitLabDiscussion, GitLabIssue, GitLabLabelEvent, GitLabMergeRequest, GitLabMilestoneEvent,
GitLabProject, GitLabStateEvent, GitLabUser, GitLabVersion,
};
use crate::core::error::{LoreError, Result};
@@ -550,6 +551,152 @@ impl GitLabClient {
}
}
/// Resource events API methods.
///
/// These endpoints return per-entity events (not project-wide), so they collect
/// all pages into a Vec rather than using streaming.
impl GitLabClient {
/// Fetch all pages from a paginated endpoint, returning collected results.
async fn fetch_all_pages<T: serde::de::DeserializeOwned>(
&self,
path: &str,
) -> Result<Vec<T>> {
let mut results = Vec::new();
let mut page = 1u32;
let per_page = 100u32;
loop {
let params = vec![
("per_page", per_page.to_string()),
("page", page.to_string()),
];
let (items, headers) = self
.request_with_headers::<Vec<T>>(path, &params)
.await?;
let is_empty = items.is_empty();
let full_page = items.len() as u32 == per_page;
results.extend(items);
let next_page = headers
.get("x-next-page")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u32>().ok());
match next_page {
Some(next) if next > page => page = next,
_ => {
if is_empty || !full_page {
break;
}
page += 1;
}
}
}
Ok(results)
}
/// Fetch state events for an issue.
pub async fn fetch_issue_state_events(
&self,
gitlab_project_id: i64,
iid: i64,
) -> Result<Vec<GitLabStateEvent>> {
let path = format!(
"/api/v4/projects/{gitlab_project_id}/issues/{iid}/resource_state_events"
);
self.fetch_all_pages(&path).await
}
/// Fetch label events for an issue.
pub async fn fetch_issue_label_events(
&self,
gitlab_project_id: i64,
iid: i64,
) -> Result<Vec<GitLabLabelEvent>> {
let path = format!(
"/api/v4/projects/{gitlab_project_id}/issues/{iid}/resource_label_events"
);
self.fetch_all_pages(&path).await
}
/// Fetch milestone events for an issue.
pub async fn fetch_issue_milestone_events(
&self,
gitlab_project_id: i64,
iid: i64,
) -> Result<Vec<GitLabMilestoneEvent>> {
let path = format!(
"/api/v4/projects/{gitlab_project_id}/issues/{iid}/resource_milestone_events"
);
self.fetch_all_pages(&path).await
}
/// Fetch state events for a merge request.
pub async fn fetch_mr_state_events(
&self,
gitlab_project_id: i64,
iid: i64,
) -> Result<Vec<GitLabStateEvent>> {
let path = format!(
"/api/v4/projects/{gitlab_project_id}/merge_requests/{iid}/resource_state_events"
);
self.fetch_all_pages(&path).await
}
/// Fetch label events for a merge request.
pub async fn fetch_mr_label_events(
&self,
gitlab_project_id: i64,
iid: i64,
) -> Result<Vec<GitLabLabelEvent>> {
let path = format!(
"/api/v4/projects/{gitlab_project_id}/merge_requests/{iid}/resource_label_events"
);
self.fetch_all_pages(&path).await
}
/// Fetch milestone events for a merge request.
pub async fn fetch_mr_milestone_events(
&self,
gitlab_project_id: i64,
iid: i64,
) -> Result<Vec<GitLabMilestoneEvent>> {
let path = format!(
"/api/v4/projects/{gitlab_project_id}/merge_requests/{iid}/resource_milestone_events"
);
self.fetch_all_pages(&path).await
}
/// Fetch all three event types for an entity in one call.
pub async fn fetch_all_resource_events(
&self,
gitlab_project_id: i64,
entity_type: &str,
iid: i64,
) -> Result<(Vec<GitLabStateEvent>, Vec<GitLabLabelEvent>, Vec<GitLabMilestoneEvent>)> {
match entity_type {
"issue" => {
let state = self.fetch_issue_state_events(gitlab_project_id, iid).await?;
let label = self.fetch_issue_label_events(gitlab_project_id, iid).await?;
let milestone = self.fetch_issue_milestone_events(gitlab_project_id, iid).await?;
Ok((state, label, milestone))
}
"merge_request" => {
let state = self.fetch_mr_state_events(gitlab_project_id, iid).await?;
let label = self.fetch_mr_label_events(gitlab_project_id, iid).await?;
let milestone = self.fetch_mr_milestone_events(gitlab_project_id, iid).await?;
Ok((state, label, milestone))
}
_ => Err(LoreError::Other(format!(
"Invalid entity type for resource events: {entity_type}"
))),
}
}
}
/// Page result for merge request pagination.
#[derive(Debug)]
pub struct MergeRequestPage {