complete refactor
This commit is contained in:
282
Blueberry.Redmine/RedmineApiClient.cs
Normal file
282
Blueberry.Redmine/RedmineApiClient.cs
Normal file
@@ -0,0 +1,282 @@
|
||||
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<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 = [];
|
||||
|
||||
while (true)
|
||||
{
|
||||
var 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;
|
||||
}
|
||||
|
||||
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>> 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<IssueList.Root, IssueList.Issue>(HttpMethod.Get, path, limit, (x) => x.Issues,
|
||||
progress, token: token);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<List<ProjectList.Project>> GetProjects(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>> GetTrackersForProject(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> 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<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<DetailedIssue.Issue> GetIssue(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> GetSimpleIssue(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 SetIssueStatus(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<int> 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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user