using Blueberry.Redmine.Dto; using Microsoft.Extensions.Logging; 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 = []; 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; } 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); } 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 } }; var privatePayload = new { issue = new { private_notes = comment } }; if(isPrivate) await SendRequestAsync(HttpMethod.Put, path, privatePayload, token: token); else 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); } } }