use std::collections::BTreeMap;
use crate::domain::model::body::Body;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::entry_origin::EntryOrigin;
use crate::domain::model::event::State;
use crate::domain::model::event::{Event, EventAction};
use crate::domain::model::issue::{Issue, Tracker};
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::Status;
use crate::domain::model::tag_list::TagList;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::model::temporal::timestamp::Timestamp;
use crate::domain::model::title::Title;
use crate::domain::usecases::source::IssueSource;
use super::http_client::HttpClient;
pub struct GitLabSource<'a> {
client: &'a dyn HttpClient,
base_url: String,
project: String,
token: String,
status_map: BTreeMap<String, String>,
id_prefix: String,
}
impl<'a> GitLabSource<'a> {
pub fn new(client: &'a dyn HttpClient, base_url: &str, project: &str, token: &str) -> Self {
GitLabSource {
client,
base_url: base_url.trim_end_matches('/').to_string(),
project: project.to_string(),
token: token.to_string(),
status_map: BTreeMap::new(),
id_prefix: "ISSUE".to_string(),
}
}
pub fn with_status_map(mut self, map: BTreeMap<String, String>) -> Self {
self.status_map = map;
self
}
pub fn with_id_prefix(mut self, prefix: &str) -> Self {
self.id_prefix = prefix.to_string();
self
}
fn map_state(&self, gitlab_state: &str) -> String {
if let Some(mapped) = self.status_map.get(gitlab_state) {
return mapped.clone();
}
match gitlab_state {
"opened" => "open".to_string(),
"closed" => "closed".to_string(),
other => other.to_string(),
}
}
fn api_url(&self, path: &str) -> String {
let encoded_project = self.project.replace('/', "%2F");
format!(
"{}/api/v4/projects/{}/{}",
self.base_url, encoded_project, path
)
}
fn headers(&self) -> Vec<(&str, &str)> {
vec![("PRIVATE-TOKEN", &self.token)]
}
const MAX_PAGES: u32 = 1000;
fn fetch_all_issues_json(&self) -> anyhow::Result<Vec<serde_json::Value>> {
let mut all = Vec::new();
let mut page = 1u32;
loop {
if page > Self::MAX_PAGES {
anyhow::bail!(
"pagination limit reached ({} pages, {} issues fetched) — aborting",
Self::MAX_PAGES,
all.len()
);
}
let url = format!("{}?per_page=100&page={page}", self.api_url("issues"));
let headers = self.headers();
let resp = self.client.get(&url, &headers)?;
if resp.status != 200 {
anyhow::bail!(
"GitLab API returned status {}: {}",
resp.status,
resp.body.chars().take(200).collect::<String>()
);
}
let items: Vec<serde_json::Value> = serde_json::from_str(&resp.body)?;
if items.is_empty() {
break;
}
all.extend(items);
page += 1;
}
Ok(all)
}
}
impl GitLabSource<'_> {
fn fetch_state_events(&self, iid: u32) -> anyhow::Result<Vec<Event>> {
let url = format!(
"{}?per_page=100",
self.api_url(&format!("issues/{iid}/resource_state_events"))
);
let headers = self.headers();
let resp = self.client.get(&url, &headers)?;
if resp.status != 200 {
return Ok(vec![]); }
let items: Vec<serde_json::Value> = serde_json::from_str(&resp.body)?;
let mut events = Vec::new();
for item in &items {
let state = match item.get("state").and_then(|v| v.as_str()) {
Some(s) => s,
None => continue,
};
let ts_raw = match item.get("created_at").and_then(|v| v.as_str()) {
Some(s) => s,
None => continue,
};
let ts = match Timestamp::new(&normalize_gitlab_timestamp(ts_raw)) {
Ok(t) => t,
Err(_) => continue,
};
let (from, to) = match state {
"closed" => (self.map_state("opened"), self.map_state("closed")),
"reopened" => (self.map_state("closed"), self.map_state("opened")),
_ => continue,
};
events.push(Event {
timestamp: ts,
action: EventAction::StatusChanged {
from: State::new(&from)
.map_err(|e| anyhow::anyhow!("invalid state '{from}': {e}"))?,
to: State::new(&to)
.map_err(|e| anyhow::anyhow!("invalid state '{to}': {e}"))?,
},
});
}
Ok(events)
}
}
impl IssueSource for GitLabSource<'_> {
fn list_issues(&self) -> anyhow::Result<Vec<Issue>> {
let items = self.fetch_all_issues_json()?;
let mut issues = Vec::new();
for item in &items {
match self.parse_issue(item) {
Ok(mut issue) => {
let iid = item.get("iid").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
match self.fetch_state_events(iid) {
Ok(state_events) => {
for event in state_events {
issue.events.push(event);
}
if let Some(latest) = issue.events.latest_state() {
issue.status = Status::unresolved(latest.as_str());
}
}
Err(e) => {
eprintln!("warning: could not fetch events for #{iid}: {e}");
}
}
issues.push(issue);
}
Err(e) => {
let iid = item.get("iid").and_then(|v| v.as_u64()).unwrap_or(0);
eprintln!("warning: skipping GitLab issue #{iid}: {e}");
}
}
}
Ok(issues)
}
}
impl GitLabSource<'_> {
fn parse_issue(&self, item: &serde_json::Value) -> anyhow::Result<Issue> {
let iid = item
.get("iid")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("missing iid"))? as u32;
let title_str = item
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing title"))?;
let state = item
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("opened");
let status_name = self.map_state(state);
let created_at = item
.get("created_at")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing created_at"))?;
let description = item
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
let tags: TagList = item
.get("labels")
.and_then(|v| v.as_array())
.map(|labels| {
labels
.iter()
.filter_map(|label| label.as_str())
.filter_map(|l| crate::domain::model::tag::Tag::new(l).ok())
.collect()
})
.unwrap_or_default();
let assignee = item
.get("assignee")
.and_then(|v| v.get("username"))
.and_then(|v| v.as_str())
.and_then(|u| crate::domain::model::issue::Assignee::new(u).ok());
let due_date = item
.get("due_date")
.and_then(|v| v.as_str())
.and_then(|d| IsoDate::new(d).ok());
let id = IssueRef::new(format!("{}-{iid:04}", self.id_prefix))
.map_err(|e| anyhow::anyhow!("invalid issue ref: {e}"))?;
let tracker = Tracker::new(&format!("gitlab:{}#{iid}", self.project))?;
let normalized_ts = normalize_gitlab_timestamp(created_at);
let timestamp = Timestamp::new(&normalized_ts)
.map_err(|e| anyhow::anyhow!("invalid timestamp: {e}"))?;
let status = Status::unresolved(&status_name);
let initial_state = State::new(&status_name)
.map_err(|e| anyhow::anyhow!("invalid state '{status_name}': {e}"))?;
let created_event = Event {
timestamp,
action: EventAction::Created {
state: initial_state,
},
};
Ok(Issue {
id,
title: Title::new(title_str).map_err(|e| anyhow::anyhow!("invalid title: {e}"))?,
description: None,
status,
date: IsoDate::new(&created_at[..10])?,
tags,
aliases: Vec::new(),
content: Body::new(description),
events: [created_event].into_iter().collect(),
links: crate::domain::model::issue::IssueLinks::new(),
relates: crate::domain::model::relates::Relates::default(),
assignee,
due_date,
tracker,
origin: EntryOrigin::Local,
location: EntryLocator::default(),
})
}
}
fn normalize_gitlab_timestamp(ts: &str) -> String {
if let Some(dot_pos) = ts.find('.') {
format!("{}Z", &ts[..dot_pos])
} else {
ts.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infra::driven::gitlab::http_client::HttpResponse;
struct StubClient {
issues_body: String,
state_events_body: String,
}
impl StubClient {
fn with_issues(json: String) -> Self {
StubClient {
issues_body: json,
state_events_body: "[]".to_string(),
}
}
fn with_state_events(mut self, json: String) -> Self {
self.state_events_body = json;
self
}
}
impl HttpClient for StubClient {
fn get(&self, url: &str, _headers: &[(&str, &str)]) -> anyhow::Result<HttpResponse> {
let body = if url.contains("resource_state_events") {
self.state_events_body.clone()
} else {
let is_first_page = url.contains("page=1&") || url.ends_with("page=1");
if is_first_page || !url.contains("page=") {
self.issues_body.clone()
} else {
"[]".to_string()
}
};
Ok(HttpResponse { status: 200, body })
}
}
fn gitlab_issue_json(iid: u32, title: &str, state: &str) -> String {
format!(
r#"{{
"iid": {iid},
"title": "{title}",
"state": "{state}",
"created_at": "2026-03-10T08:00:00Z",
"description": "Issue body.",
"labels": ["bug", "urgent"],
"assignee": {{ "username": "alice" }},
"due_date": "2026-04-01"
}}"#
)
}
#[test]
fn parses_a_single_gitlab_issue() {
let json = format!("[{}]", gitlab_issue_json(42, "Fix login", "opened"));
let client = StubClient::with_issues(json);
let source = GitLabSource::new(&client, "https://gitlab.com", "group/project", "token");
let issues = source.list_issues().unwrap();
assert_eq!(issues.len(), 1);
let issue = &issues[0];
assert_eq!(issue.title.as_str(), "Fix login");
assert_eq!(issue.status.as_str(), "open");
assert_eq!(issue.tracker.system(), "gitlab");
assert_eq!(issue.tracker.locator(), "group/project#42");
assert_eq!(issue.assignee.as_ref().unwrap().as_str(), "alice");
assert_eq!(issue.due_date.as_ref().unwrap().as_str(), "2026-04-01");
assert_eq!(issue.events.len(), 1);
assert!(issue.events[0].action.is_created());
}
#[test]
fn maps_closed_state() {
let json = format!("[{}]", gitlab_issue_json(1, "Done", "closed"));
let client = StubClient::with_issues(json);
let source = GitLabSource::new(&client, "https://gitlab.com", "g/p", "t");
let issues = source.list_issues().unwrap();
assert_eq!(issues[0].status.as_str(), "closed");
}
#[test]
fn handles_empty_response() {
let client = StubClient::with_issues("[]".to_string());
let source = GitLabSource::new(&client, "https://gitlab.com", "g/p", "t");
let issues = source.list_issues().unwrap();
assert!(issues.is_empty());
}
#[test]
fn api_error_is_reported() {
struct ErrorClient;
impl HttpClient for ErrorClient {
fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> anyhow::Result<HttpResponse> {
Ok(HttpResponse {
status: 401,
body: "Unauthorized".to_string(),
})
}
}
let source = GitLabSource::new(&ErrorClient, "https://gitlab.com", "g/p", "bad");
let result = source.list_issues();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("401"));
}
#[test]
fn labels_become_tags() {
let json = format!("[{}]", gitlab_issue_json(1, "T", "opened"));
let client = StubClient::with_issues(json);
let source = GitLabSource::new(&client, "https://gitlab.com", "g/p", "t");
let issues = source.list_issues().unwrap();
assert!(issues[0].tags.iter().any(|t| t.as_str() == "bug"));
}
#[test]
fn state_events_produce_status_changed_events() {
let issues_json = format!("[{}]", gitlab_issue_json(1, "Feature", "closed"));
let events_json = r#"[
{"state": "closed", "created_at": "2026-03-12T10:00:00Z"},
{"state": "reopened", "created_at": "2026-03-14T08:00:00Z"},
{"state": "closed", "created_at": "2026-03-15T16:00:00Z"}
]"#;
let client =
StubClient::with_issues(issues_json).with_state_events(events_json.to_string());
let source = GitLabSource::new(&client, "https://gitlab.com", "g/p", "t");
let issues = source.list_issues().unwrap();
let issue = &issues[0];
assert_eq!(issue.events.len(), 4);
assert!(issue.events[0].action.is_created());
assert!(issue.events[1].action.is_status_changed());
assert!(issue.events[2].action.is_status_changed());
assert!(issue.events[3].action.is_status_changed());
assert_eq!(issue.status.as_str(), "closed");
}
#[test]
fn status_map_overrides_default_state_translation() {
let json = format!("[{}]", gitlab_issue_json(1, "T", "opened"));
let client = StubClient::with_issues(json);
let mut map = BTreeMap::new();
map.insert("opened".to_string(), "in-progress".to_string());
map.insert("closed".to_string(), "done".to_string());
let source =
GitLabSource::new(&client, "https://gitlab.com", "g/p", "t").with_status_map(map);
let issues = source.list_issues().unwrap();
assert_eq!(issues[0].status.as_str(), "in-progress");
}
#[test]
fn status_map_applies_to_state_events() {
let issues_json = format!("[{}]", gitlab_issue_json(1, "T", "closed"));
let events_json = r#"[
{"state": "closed", "created_at": "2026-03-12T10:00:00Z"},
{"state": "reopened", "created_at": "2026-03-13T10:00:00Z"}
]"#;
let client =
StubClient::with_issues(issues_json).with_state_events(events_json.to_string());
let mut map = BTreeMap::new();
map.insert("opened".to_string(), "in-progress".to_string());
map.insert("closed".to_string(), "done".to_string());
let source =
GitLabSource::new(&client, "https://gitlab.com", "g/p", "t").with_status_map(map);
let issues = source.list_issues().unwrap();
assert_eq!(issues[0].events.len(), 3);
if let EventAction::StatusChanged { from, to } = &issues[0].events[1].action {
assert_eq!(from.as_str(), "in-progress");
assert_eq!(to.as_str(), "done");
} else {
panic!("expected StatusChanged");
}
if let EventAction::StatusChanged { from, to } = &issues[0].events[2].action {
assert_eq!(from.as_str(), "done");
assert_eq!(to.as_str(), "in-progress");
} else {
panic!("expected StatusChanged");
}
}
#[test]
fn state_events_with_milliseconds_are_normalized() {
let issues_json = format!("[{}]", gitlab_issue_json(1, "T", "closed"));
let events_json = r#"[{"state": "closed", "created_at": "2026-03-12T10:00:00.123Z"}]"#;
let client =
StubClient::with_issues(issues_json).with_state_events(events_json.to_string());
let source = GitLabSource::new(&client, "https://gitlab.com", "g/p", "t");
let issues = source.list_issues().unwrap();
assert_eq!(issues[0].events.len(), 2);
}
}