using Blueberry.Redmine.Dto; using Microsoft.Extensions.Logging; using System.Diagnostics; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using static Blueberry.Redmine.Dto.UserTime; 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 = $"{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> GetOpenIssuesByAssignee(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> GetProjects(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> GetTrackersForProject(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 GetTotalTimeForUser(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 GetIssue(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 GetSimpleIssue(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 SetIssueStatus(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> GetTimeOnIssue(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 CreateNewIssue(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); } } }