using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Collections.Concurrent; using static BlueMine.Redmine.RedmineDto; using Microsoft.Extensions.Logging; namespace BlueMine.Redmine { public class RedmineConnect : IRedmineConnect { readonly HttpClient _httpClient; private readonly JsonSerializerOptions _jsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly SemaphoreSlim _concurrencySemaphore; private readonly RedmineConfig _config; private readonly ILogger _logger; private int _userId = -1; public RedmineConnect(HttpClient client, ILogger logger, RedmineConfig config) { ArgumentNullException.ThrowIfNull(client); ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(config); _httpClient = client; _logger = logger; _config = config; _concurrencySemaphore = new(config.ConcurrencyLimit, config.ConcurrencyLimit); } private async Task SendRequestAsync(HttpMethod method, string endpoint, object? payload = null, CancellationToken? token = null) { string url = $"{_config.RedmineUrl}/{endpoint}"; int maxRetries = _config.MaxRetries; int retryDelayMilliseconds = 2000; 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, retryDelayMilliseconds, attempt + 1, maxRetries); response.Dispose(); await Task.Delay(retryDelayMilliseconds).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"); } public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null) { string url = $"time_entries.json"; string dateString = (date ?? DateTime.Now).ToString("yyyy-MM-dd"); var payload = new TimeEntryRequest { TimeEntry = new TimeEntry { IssueId = issueId, Hours = hours, Comments = comments, SpentOn = dateString, ActivityId = activityId } }; var response = await SendRequestAsync(HttpMethod.Post, url, payload); } public async Task CloseIssueAsync(int issueId) { _logger.LogDebug("Closing issue {IssueId}", issueId); _logger.LogInformation("Closing issue {IssueId}", issueId); string url = $"issues/{issueId}.json"; var payload = new { issue = new { status_id = RedmineConstants.ClosedStatusId } }; var response = await SendRequestAsync(HttpMethod.Put, url, payload); _logger.LogInformation("Closed {IssueId}", issueId); _logger.LogDebug("Issue {IssueId} closed successfully", issueId); } public async Task CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null) { _logger.LogDebug("Creating issue in project {ProjectId} with subject '{Subject}'", projectId, subject); if (_userId == -1) { await VerifyApiKey(); } string url = $"issues.json"; var issueDict = new Dictionary { ["project_id"] = projectId, ["tracker_id"] = trackerId, ["subject"] = subject, ["assigned_to_id"] = _userId }; if (!string.IsNullOrEmpty(description)) issueDict["description"] = description; if (estimatedHours.HasValue) issueDict["estimated_hours"] = estimatedHours.Value; if (priorityId.HasValue) issueDict["priority_id"] = priorityId.Value; if (parentIssueId.HasValue) issueDict["parent_issue_id"] = parentIssueId.Value; if (estimatedHours.HasValue) { issueDict["custom_fields"] = new[] { new { id = RedmineConstants.EstimatedHoursCustomFieldId, value = estimatedHours.Value.ToString() } }; } var issue = issueDict; var payload = new { issue }; var response = await SendRequestAsync(HttpMethod.Post, url, payload); var issueId = response?.Issue?.Id ?? throw new Exception("Failed to parse created issue response"); _logger.LogInformation("Issue {IssueId} created with subject '{Subject}'", issueId, subject); _logger.LogDebug("Issue {IssueId} created successfully", issueId); return issueId; } public async Task> GetProjectsAsync(int limit = 25, IProgress<(int, int)>? progress = null) { int offset = 0; int totalCount = 0; var projects = new ConcurrentBag(); while (true) { string url = $"projects.json?limit={limit}&offset={offset}"; var response = await SendRequestAsync(HttpMethod.Get, url); if (response?.Projects != null) { totalCount = response.TotalCount; foreach (var p in response.Projects) { projects.Add(new SimpleProject { Id = p.Id, Name = p.Name, Identifier = p.Identifier }); } progress?.Report((offset + response.Projects.Count, totalCount)); } if (response == null || offset + limit >= totalCount) break; offset += limit; } _logger.LogInformation("Fetched projects from API"); return projects; } public async Task> GetTrackersAsync(string projectId, CancellationToken? token = null) { string url = $"projects/{projectId}.json?include=trackers"; var response = await SendRequestAsync(HttpMethod.Get, url, token: token); var trackers = response?.Project?.Trackers.Select(t => new SimpleTracker { Id = t.Id, Name = t.Name }).ToList() ?? new List(); _logger.LogInformation("Fetched {Count} trackers from API", trackers.Count); return trackers; } public async Task VerifyApiKey() { _logger.LogDebug("Verifying API key"); _logger.LogInformation("Verifying API Key and fetching user ID"); const int maxAttempts = 3; for (int attempts = 0; attempts < maxAttempts; attempts++) { string url = $"issues.json?assigned_to_id=me&status_id=open&limit=1"; var response = await SendRequestAsync(HttpMethod.Get, url); var userid = response?.Issues.FirstOrDefault()?.AssignedTo?.Id; if (userid != null && userid != -1) { _userId = userid.Value; _logger.LogInformation("API Key verified. User ID: {UserId}", _userId); _logger.LogDebug("User ID set to {UserId}", _userId); return; } _logger.LogDebug("User ID not found, retrying (attempt {Attempt}/{Max})", attempts + 1, maxAttempts); if (attempts < maxAttempts - 1) { await Task.Delay(1000); // short delay } } throw new InvalidOperationException("Failed to verify API key after maximum attempts"); } public async Task> GetMyIssuesAsync(int limit = 25, IProgress<(int, int)>? progress = null) { var offset = 0; int totalCount = 0; var issues = new ConcurrentBag(); while(true) { string url = $"issues.json?assigned_to_id=me&status_id=open&limit={limit}&offset={offset}"; var response = await SendRequestAsync(HttpMethod.Get, url); if (response?.Issues != null) { totalCount = response.TotalCount; foreach (var i in response.Issues) { issues.Add(new SimpleIssue { Id = i.Id, ProjectName = i.Project?.Name ?? "Unknown", Subject = i.Subject, Description = i.Description, Created = i.Created, Updated = i.Updated }); } progress?.Report((offset + response.Issues.Count, totalCount)); } if (response == null || offset + limit >= totalCount) break; offset += limit; } _logger.LogInformation("Fetched issues from API"); return issues; } public async Task GetIssueTotalTimeAsync(int issueId) { string url = $"time_entries.json?issue_id={issueId}&limit=100"; var response = await SendRequestAsync(HttpMethod.Get, url); if (response?.TimeEntries != null) { return response.TimeEntries.Sum(t => t.Hours); } return 0.0; } public async Task GetTodaysHoursAsync(DateTime startDate, DateTime endDate) { _logger.LogDebug("Getting hours from {StartDate} to {EndDate}", startDate.ToShortDateString(), endDate.ToShortDateString()); string start = startDate.ToString("yyyy-MM-dd"); string end = endDate.ToString("yyyy-MM-dd"); string url = $"time_entries.json?from={start}&to={end}&user_id={_userId}"; var response = await SendRequestAsync(HttpMethod.Get, url); double total = response?.TimeEntries?.Sum(t => t.Hours) ?? 0; _logger.LogInformation("Fetched hours: {Total}", total); _logger.LogDebug("Total hours: {Total}", total); return total; } public async Task> GetSpentTimeForIssuesAsync(List simpleIssues, IProgress<(int, int)>? progress = null) { _logger.LogDebug("Getting current issues with spent time"); _logger.LogDebug("Retrieved {Count} simple issues", simpleIssues.Count); var issueItems = new ConcurrentBag(); var tasks = new List(); for (int i = 0; i < simpleIssues.Count; i++) { SimpleIssue si = simpleIssues[i]; var task = Task.Run(async () => { await _concurrencySemaphore.WaitAsync(); try { var spent = await GetIssueTotalTimeAsync(si.Id); issueItems.Add(new IssueItem { ProjectName = si.ProjectName, IssueName = si.Subject, IssueNumber = si.Id, IssueDescription = si.Description, Updated = si.Updated, Created = si.Created, SpentTime = spent }); _logger.LogDebug("Retrieved total time for issue {IssueId}: {Spent} hours", si.Id, spent); } finally { _concurrencySemaphore.Release(); progress?.Report((issueItems.Count, simpleIssues.Count)); } }); tasks.Add(task); } await Task.WhenAll(tasks); _logger.LogInformation("Processed {Count} issues with spent time", issueItems.Count); _logger.LogDebug("Finished processing issues"); return [.. issueItems]; } } }