Files
Blueberry/Blueberry.Redmine/RedmineApiClient.cs

546 lines
22 KiB
C#

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<RedmineApiClient> _logger;
readonly HttpClient _httpClient;
public RedmineApiClient(RedmineConfig config, ILogger<RedmineApiClient> logger, HttpClient httpClient)
{
_config = config;
_logger = logger;
_httpClient = httpClient;
}
private async Task<TResponse?> SendRequestAsync<TResponse>(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<TResponse>(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<List<TReturn>> SendRequestWithPagingAsync<TResponse, TReturn>(HttpMethod method, string endpoint, int limit, Func<TResponse, List<TReturn>> itemParser,
IProgress<(int current, int total)>? progress = null, object? payload = null, CancellationToken? token = null) where TResponse : IResponseList
{
var offset = 0;
List<TReturn> 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<TResponse>(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<List<TReturn>> SendRequestWithPagingAsync<TResponse, TReturn>(HttpMethod method, string endpoint, int limit, Func<TResponse, List<TReturn>> itemParser,
IProgress<(int current, int total)>? progress = null, object? payload = null, CancellationToken? token = null) where TResponse : IResponseList
{
var offset = 0;
List<TReturn> 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<TResponse>(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<TReturn> 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<TResponse>(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<List<StatusList.IssueStatus>> GetStatusesAsync(CancellationToken? token = null)
{
var path = "issue_statuses.json";
var statusList = await SendRequestAsync<StatusList.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return statusList.IssueStatuses;
}
public async Task<List<CustomFieldList.CustomField>> GetCustomFieldsAsync(CancellationToken? token = null)
{
var path = "custom_fields.json";
var fields = await SendRequestAsync<CustomFieldList.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return fields.CustomFields;
}
public async Task<List<PriorityList.IssuePriority>> GetPrioritiesAsync(CancellationToken? token = null)
{
var path = "enumerations/issue_priorities.json";
var fields = await SendRequestAsync<PriorityList.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return fields.IssuePriorities;
}
public async Task<List<IssueList.Issue>> 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<IssueList.Root, IssueList.Issue>(HttpMethod.Get, path, limit, (x) => x.Issues,
progress, token: token);
return items;
}
public async Task<List<IssueList.Issue>> 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<string>();
// 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<IssueList.Root, IssueList.Issue>(
HttpMethod.Get, path, limit, (x) => x.Issues, progress, token: token);
}
public async Task<List<IssueList.Issue>> 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<string>();
// 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<IssueList.Root, IssueList.Issue>(
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: "><START|END" (URL encoded as %3E%3C)
return $"{paramName}=%3E%3C{from.Value.ToString(format)}|{to.Value.ToString(format)}";
}
else if (from.HasValue)
{
// After: ">=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<List<ProjectList.Project>> GetProjectsAsync(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
{
var path = $"projects.json";
var items = await SendRequestWithPagingAsync<ProjectList.Root, ProjectList.Project>(HttpMethod.Get, path, limit, (x) => x.Projects,
progress, token: token);
return items;
}
public async Task<List<ProjectTrackers.Tracker>> GetTrackersForProjectAsync(string projectId, CancellationToken? token = null)
{
var path = $"projects/{projectId}.json?include=trackers";
var trackers = await SendRequestAsync<ProjectTrackers.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return trackers.Project.Trackers;
}
public async Task<double> 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<UserTime.Root, UserTime.TimeEntry>(HttpMethod.Get, path, limit, (x) => x.TimeEntries, progress, token: token);
var hours = timedata.Sum(x => x.Hours);
return hours;
}
public async Task<List<UserTime.TimeEntry>> 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<UserTime.Root, UserTime.TimeEntry>(HttpMethod.Get, path, limit, (x) => x.TimeEntries, progress, token: token);
}
public async Task<DetailedIssue.Issue> GetIssueAsync(int issueId, CancellationToken? token = null)
{
var path = $"issues/{issueId}.json?include=journals";
var issue = await SendRequestAsync<DetailedIssue.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return issue.Issue;
}
public async Task<IssueList.Issue> GetSimpleIssueAsync(int issueId, CancellationToken? token = null)
{
var path = $"issues/{issueId}.json?include=journals";
var issue = await SendRequestAsync<IssueList.SimpleRoot>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return issue.Issue;
}
public async Task<UserInfo.User> GetUserAsync(int? userId = null, CancellationToken? token = null)
{
var path = "users/current.json";
if (userId != null)
path = $"users/{userId}.json";
var user = await SendRequestAsync<UserInfo.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return user.User;
}
public async Task<List<UserInfo.User>> GetUsersAsync(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
{
var path = "users.json";
return [.. (await SendRequestWithPagingAsync<UserList.Root, UserInfo.User>(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<object>(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<object>(HttpMethod.Put, path, payload, token: token);
}
public async Task<List<TimeOnIssue.TimeEntry>> 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<TimeOnIssue.Root, TimeOnIssue.TimeEntry>(HttpMethod.Get, path, limit, (x)=>x.TimeEntries, progress, token: token);
return times;
}
public async Task<int> 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<NewIssue.Root>(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<object>(HttpMethod.Post, url, payload, token: token);
}
}
}