using Blueberry.Redmine.Dto; using Microsoft.Extensions.Logging; using System.ComponentModel.DataAnnotations; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace Blueberry.Redmine { public class RedmineApiClient { private const int RETRY_DELAY_MS = 2000; private const int PAGING_LIMIT = 50; private readonly RedmineConfig _config; private readonly JsonSerializerOptions _jsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly ILogger _logger; readonly HttpClient _httpClient; public RedmineApiClient(RedmineConfig config, ILogger logger, HttpClient httpClient) { _config = config; _logger = logger; _httpClient = httpClient; } private async Task SendRequestAsync(HttpMethod method, string endpoint, object? payload = null, CancellationToken? token = null) { string url = $"{_config.RedmineUrl}/{endpoint}"; int maxRetries = _config.MaxRetries; CancellationToken cancellationToken = token ?? CancellationToken.None; for (int attempt = 0; attempt <= maxRetries; attempt++) { using var request = new HttpRequestMessage(method, url); request.Headers.Add("X-Redmine-API-Key", _config.ApiKey); if (payload != null) { string json = JsonSerializer.Serialize(payload, _jsonOptions); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } if (cancellationToken.IsCancellationRequested) { _logger.LogInformation("Request cancelled by token"); cancellationToken.ThrowIfCancellationRequested(); } var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { if (response.Content.Headers.ContentLength == 0) return default; var responseStream = await response.Content.ReadAsStreamAsync(); return await JsonSerializer.DeserializeAsync(responseStream, _jsonOptions); } bool isServerError = (int)response.StatusCode >= 500 && (int)response.StatusCode < 600; if (isServerError && attempt < maxRetries) { _logger.LogWarning("Received {StatusCode} from Redmine. Retrying in {Delay}ms (Attempt {Attempt} of {MaxRetries})", response.StatusCode, RETRY_DELAY_MS, attempt + 1, maxRetries); response.Dispose(); await Task.Delay(RETRY_DELAY_MS).ConfigureAwait(false); continue; } string errorBody = await response.Content.ReadAsStringAsync(); response.Dispose(); _logger.LogError("Error ({StatusCode}): {ErrorBody}", response.StatusCode, errorBody); throw new RedmineApiException($"Redmine API Error {response.StatusCode}: {errorBody}", (int)response.StatusCode); } throw new RedmineApiException("Redmine API Unreachable"); } /* private async Task> SendRequestWithPagingAsync(HttpMethod method, string endpoint, int limit, Func> itemParser, IProgress<(int current, int total)>? progress = null, object? payload = null, CancellationToken? token = null) where TResponse : IResponseList { var offset = 0; List returnList = []; _logger.LogDebug("Starting paged request to {Endpoint} with limit {Limit}", endpoint, limit); while (true) { var path = ""; if (endpoint.Contains('?')) path = $"{endpoint}&limit={limit}&offset={offset}"; else path = $"{endpoint}?limit={limit}&offset={offset}"; var responseList = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); returnList.AddRange(itemParser(responseList)); if (offset + limit >= responseList.TotalCount) break; offset += limit; if (progress != null) { var current = Math.Min(offset + limit, responseList.TotalCount); progress.Report((current, responseList.TotalCount)); } } return returnList; } */ private async Task> SendRequestWithPagingAsync(HttpMethod method, string endpoint, int limit, Func> itemParser, IProgress<(int current, int total)>? progress = null, object? payload = null, CancellationToken? token = null) where TResponse : IResponseList { var offset = 0; List returnList = []; _logger.LogDebug("Starting paged request to {Endpoint} with limit {Limit}", endpoint, limit); var path = ""; if (endpoint.Contains('?')) path = $"{endpoint}&limit={limit}&offset={offset}"; else path = $"{endpoint}?limit={limit}&offset={offset}"; var responseList = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); var total = responseList.TotalCount; returnList.AddRange(itemParser(responseList)); offset += limit; if (offset >= responseList.TotalCount) return returnList; var remain = total - offset; var tasks = new Task[(int)Math.Ceiling((double)remain / limit)]; _logger.LogDebug("Spawning {TaskCount} tasks for remaining {Remaining} items out of {Total}", tasks.Length, remain, total); List<(int id, IEnumerable items)> responses = []; for (int i = 0; i < tasks.Length; i++) { var id = i; var o = offset; tasks[i] = Task.Run(async () => { if (endpoint.Contains('?')) path = $"{endpoint}&limit={limit}&offset={o}"; else path = $"{endpoint}?limit={limit}&offset={o}"; var responseList = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); responses.Add((id, itemParser(responseList))); }); offset += limit; } while (tasks.Any(t => !t.IsCompleted)) { if (progress != null) { var current = Math.Min(tasks.Count(x=>x.IsCompletedSuccessfully), tasks.Length); progress.Report((current, tasks.Length)); } var completed = tasks.Count(t => t.IsCompleted); _logger.LogDebug("{Completed}/{Total} tasks completed", completed, tasks.Length); await Task.Delay(250); } await Task.WhenAll(tasks); await Task.Delay(100); var notCompleted = tasks.Where(t => !t.IsCompletedSuccessfully).ToList(); _logger.LogDebug("{NotCompleted} tasks did not complete successfully", notCompleted.Count); _logger.LogDebug("All tasks completed, aggregating {total} results from {count} responses", responses.Select(x=>x.items).Count(), responses.Count); foreach (var resp in responses.OrderBy(x => x.id)) { returnList.AddRange(resp.items); } _logger.LogDebug("Aggregated total of {TotalItems} items", returnList.Count); return returnList; } public async Task> GetStatusesAsync(CancellationToken? token = null) { var path = "issue_statuses.json"; var statusList = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); return statusList.IssueStatuses; } public async Task> GetCustomFieldsAsync(CancellationToken? token = null) { var path = "custom_fields.json"; var fields = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); return fields.CustomFields; } public async Task> GetPrioritiesAsync(CancellationToken? token = null) { var path = "enumerations/issue_priorities.json"; var fields = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); return fields.IssuePriorities; } public async Task> GetOpenIssuesByAssigneeAsync(int userId, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var path = $"issues.json?assigned_to_id={userId}&status_id=open"; var items = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.Issues, progress, token: token); return items; } public async Task> GetIssuesAsync( int? userId = null, string? projectId = null, int? statusId = null, bool? isOpen = null, DateTime? createdOn = null, DateTime? updatedOn = null, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { // Start with the base endpoint // We use a List to build parameters cleanly to avoid formatting errors var queryParams = new List(); // 1. Handle User ID if (userId != null) queryParams.Add($"assigned_to_id={userId}"); // 2. Handle Project ID if (!string.IsNullOrEmpty(projectId)) queryParams.Add($"project_id={projectId}"); // 3. Handle Status (Prioritize explicit ID, then isOpen flag, then default to open) if (statusId != null) { queryParams.Add($"status_id={statusId}"); } else if (isOpen != null) { queryParams.Add($"status_id={(isOpen.Value ? "open" : "closed")}"); } else { // Default behavior if neither is specified (preserves your original logic) queryParams.Add("status_id=open"); } // 4. Handle Dates (Using >= operator for "Since") if (createdOn != null) { // %3E%3D is ">=" queryParams.Add($"created_on=%3E%3D{createdOn.Value:yyyy-MM-ddTHH:mm:ssZ}"); } if (updatedOn != null) { queryParams.Add($"updated_on=%3E%3D{updatedOn.Value:yyyy-MM-ddTHH:mm:ssZ}"); } // Join the path with the query string var queryString = string.Join("&", queryParams); var path = $"issues.json?{queryString}"; return await SendRequestWithPagingAsync( HttpMethod.Get, path, limit, (x) => x.Issues, progress, token: token); } public async Task> GetIssuesAsync( int? userId = null, string? projectId = null, int? statusId = null, bool? isOpen = null, // Changed single dates to From/To pairs DateTime? createdFrom = null, DateTime? createdTo = null, DateTime? updatedFrom = null, DateTime? updatedTo = null, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var queryParams = new List(); // 1. Basic Filters if (userId != null) queryParams.Add($"assigned_to_id={userId}"); if (!string.IsNullOrEmpty(projectId)) queryParams.Add($"project_id={projectId}"); // 2. Status Logic if (statusId != null) { queryParams.Add($"status_id={statusId}"); } else if (isOpen != null) { queryParams.Add($"status_id={(isOpen.Value ? "open" : "closed")}"); } else { queryParams.Add("status_id=open"); } // 3. Date Filter Logic (Helper function used below) string? createdFilter = BuildDateFilter("created_on", createdFrom, createdTo); if (createdFilter != null) queryParams.Add(createdFilter); string? updatedFilter = BuildDateFilter("updated_on", updatedFrom, updatedTo); if (updatedFilter != null) queryParams.Add(updatedFilter); // 4. Construct URL var queryString = string.Join("&", queryParams); var path = $"issues.json?{queryString}"; return await SendRequestWithPagingAsync( HttpMethod.Get, path, limit, (x) => x.Issues, progress, token: token); } // Helper method to determine the correct Redmine operator private string? BuildDateFilter(string paramName, DateTime? from, DateTime? to) { string format = "yyyy-MM-ddTHH:mm:ssZ"; // ISO 8601 if (from.HasValue && to.HasValue) { // Range: ">=DATE" (URL encoded as %3E%3D) return $"{paramName}=%3E%3D{from.Value.ToString(format)}"; } else if (to.HasValue) { // Before: "<=DATE" (URL encoded as %3C%3D) return $"{paramName}=%3C%3D{to.Value.ToString(format)}"; } return null; } public async Task> GetProjectsAsync(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var path = $"projects.json"; var items = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.Projects, progress, token: token); return items; } public async Task> GetTrackersForProjectAsync(string projectId, CancellationToken? token = null) { var path = $"projects/{projectId}.json?include=trackers"; var trackers = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); return trackers.Project.Trackers; } public async Task GetTotalTimeForUserAsync(int userId, DateTime start, DateTime end, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var sText = start.ToString("yyyy-MM-dd"); var eText = end.ToString("yyyy-MM-dd"); var path = $"time_entries.json?from={sText}&to={eText}&user_id={userId}"; var timedata = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.TimeEntries, progress, token: token); var hours = timedata.Sum(x => x.Hours); return hours; } public async Task> GetTimeForUserAsync(int userId, DateTime start, DateTime end, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var sText = start.ToString("yyyy-MM-dd"); var eText = end.ToString("yyyy-MM-dd"); var path = $"time_entries.json?from={sText}&to={eText}&user_id={userId}"; return await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.TimeEntries, progress, token: token); } public async Task GetIssueAsync(int issueId, CancellationToken? token = null) { var path = $"issues/{issueId}.json?include=journals"; var issue = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); return issue.Issue; } public async Task GetSimpleIssueAsync(int issueId, CancellationToken? token = null) { var path = $"issues/{issueId}.json?include=journals"; var issue = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); return issue.Issue; } public async Task GetUserAsync(int? userId = null, CancellationToken? token = null) { var path = "users/current.json"; if (userId != null) path = $"users/{userId}.json"; var user = await SendRequestAsync(HttpMethod.Get, path, token: token) ?? throw new NullReferenceException(); return user.User; } public async Task> GetUsersAsync(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var path = "users.json"; return [.. (await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.Users, progress, token)).OrderBy(x => x.FullName)]; } public async Task SetIssueStatusAsync(int issueId, int statusId, CancellationToken? token = null) { var path = $"issues/{issueId}.json"; var payload = new { issue = new { status_id = statusId } }; await SendRequestAsync(HttpMethod.Put, path, payload, token: token); } public async Task AddCommentToIssueAsync(int issueId, string comment, bool isPrivate, CancellationToken? token = null) { var path = $"issues/{issueId}.json"; var payload = new { issue = new { notes = comment, private_notes = isPrivate } }; await SendRequestAsync(HttpMethod.Put, path, payload, token: token); } public async Task> GetTimeOnIssueAsync(int issueId, int limit = 25, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var path = $"time_entries.json?issue_id={issueId}"; var times = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x)=>x.TimeEntries, progress, token: token); return times; } public async Task CreateNewIssueAsync(int projectId, int trackerId, string subject, string description, double estimatedHours, int priorityId, int? assigneeId = null, int? parentIssueId = null, CancellationToken? token = null) { var path = "issues.json"; var payload = new { project_id = projectId, tracker_id = trackerId, subject = subject, description = description, priority_id = priorityId, assigned_to_id = assigneeId, parent_issue_id = parentIssueId, custom_fields = new // TODO, do something about this { id = 65, value = estimatedHours } }; var issue = await SendRequestAsync(HttpMethod.Post, path, payload, token) ?? throw new NullReferenceException(); return issue.Issue.Id; } public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null, CancellationToken? token = null) { string url = $"time_entries.json"; string dateString = (date ?? DateTime.Now).ToString("yyyy-MM-dd"); var payload = new { time_entry = new { issue_id = issueId, hours = hours, comments = comments, spent_on = dateString, activity_id = activityId } }; await SendRequestAsync(HttpMethod.Post, url, payload, token: token); } } }