379 lines
15 KiB
C#
379 lines
15 KiB
C#
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<RedmineConnect> _logger;
|
|
private int _userId = -1;
|
|
|
|
public RedmineConnect(HttpClient client, ILogger<RedmineConnect> 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<TResponse?> SendRequestAsync<TResponse>(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<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, 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<object>(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<object>(HttpMethod.Put, url, payload);
|
|
_logger.LogInformation("Closed {IssueId}", issueId);
|
|
_logger.LogDebug("Issue {IssueId} closed successfully", issueId);
|
|
}
|
|
|
|
public async Task<int> 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<string, object>
|
|
{
|
|
["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<CreateIssueResponse>(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<IEnumerable<SimpleProject>> GetProjectsAsync(int limit = 25, IProgress<(int, int)>? progress = null)
|
|
{
|
|
int offset = 0;
|
|
int totalCount = 0;
|
|
var projects = new ConcurrentBag<SimpleProject>();
|
|
|
|
while (true)
|
|
{
|
|
string url = $"projects.json?limit={limit}&offset={offset}";
|
|
|
|
var response = await SendRequestAsync<ProjectListResponse>(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<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null)
|
|
{
|
|
string url = $"projects/{projectId}.json?include=trackers";
|
|
|
|
var response = await SendRequestAsync<ProjectWithTrackersResponse>(HttpMethod.Get, url, token: token);
|
|
|
|
var trackers = response?.Project?.Trackers.Select(t => new SimpleTracker
|
|
{
|
|
Id = t.Id,
|
|
Name = t.Name
|
|
}).ToList() ?? new List<SimpleTracker>();
|
|
|
|
_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<IssueListResponse>(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<IEnumerable<SimpleIssue>> GetMyIssuesAsync(int limit = 25, IProgress<(int, int)>? progress = null)
|
|
{
|
|
var offset = 0;
|
|
int totalCount = 0;
|
|
|
|
var issues = new ConcurrentBag<SimpleIssue>();
|
|
while(true)
|
|
{
|
|
string url = $"issues.json?assigned_to_id=me&status_id=open&limit={limit}&offset={offset}";
|
|
|
|
var response = await SendRequestAsync<IssueListResponse>(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<double> GetIssueTotalTimeAsync(int issueId)
|
|
{
|
|
string url = $"time_entries.json?issue_id={issueId}&limit=100";
|
|
|
|
var response = await SendRequestAsync<TimeEntryListResponse>(HttpMethod.Get, url);
|
|
|
|
if (response?.TimeEntries != null)
|
|
{
|
|
return response.TimeEntries.Sum(t => t.Hours);
|
|
}
|
|
return 0.0;
|
|
}
|
|
|
|
public async Task<double> 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<TimeEntryListResponse>(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<List<IssueItem>> GetSpentTimeForIssuesAsync(List<SimpleIssue> 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<IssueItem>();
|
|
var tasks = new List<Task>();
|
|
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];
|
|
}
|
|
}
|
|
} |