initial commit
This commit is contained in:
29
BlueMine/Redmine/AsyncLock.cs
Normal file
29
BlueMine/Redmine/AsyncLock.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public class AsyncLock
|
||||
{
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
public async Task<IDisposable> LockAsync()
|
||||
{
|
||||
await _semaphore.WaitAsync();
|
||||
return new Disposable(() => _semaphore.Release());
|
||||
}
|
||||
|
||||
private class Disposable : IDisposable
|
||||
{
|
||||
private readonly Action _action;
|
||||
|
||||
public Disposable(Action action)
|
||||
{
|
||||
_action = action;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_action();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
BlueMine/Redmine/IRedmineCache.cs
Normal file
10
BlueMine/Redmine/IRedmineCache.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public interface IRedmineCache<T>
|
||||
{
|
||||
void RefreshCache(List<T> newItems);
|
||||
void InvalidateCache();
|
||||
bool IsCacheValid();
|
||||
List<T> GetItems();
|
||||
}
|
||||
}
|
||||
18
BlueMine/Redmine/IRedmineConnect.cs
Normal file
18
BlueMine/Redmine/IRedmineConnect.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using static BlueMine.Redmine.RedmineDto;
|
||||
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public interface IRedmineConnect
|
||||
{
|
||||
Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null);
|
||||
Task CloseIssueAsync(int issueId);
|
||||
Task<int> CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null);
|
||||
Task<IEnumerable<SimpleProject>> GetProjectsAsync(int limit = 25, IProgress<(int, int)>? progress = null);
|
||||
Task<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null);
|
||||
Task VerifyApiKey();
|
||||
Task<IEnumerable<SimpleIssue>> GetMyIssuesAsync(int limit = 25, IProgress<(int, int)>? progress = null);
|
||||
Task<double> GetIssueTotalTimeAsync(int issueId);
|
||||
Task<double> GetTodaysHoursAsync(DateTime startDate, DateTime endDate);
|
||||
Task<List<IssueItem>> GetSpentTimeForIssuesAsync(List<SimpleIssue> simpleIssues, IProgress<(int, int)>? progress = null);
|
||||
}
|
||||
}
|
||||
16
BlueMine/Redmine/IRedmineManager.cs
Normal file
16
BlueMine/Redmine/IRedmineManager.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using static BlueMine.Redmine.RedmineDto;
|
||||
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public interface IRedmineManager
|
||||
{
|
||||
Task<bool> IsRedmineAvailable();
|
||||
Task LogTimeAsync(int issueId, double hours, string comments, DateTime date, int? activityId = null);
|
||||
Task CloseIssueAsync(int issueId);
|
||||
Task<int> CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null);
|
||||
Task<List<SimpleProject>> GetProjectsAsync(int limit = 100, IProgress<(int, int)>? progress = null);
|
||||
Task<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null);
|
||||
Task<List<IssueItem>> GetCurrentIssuesAsync(IProgress<(int, int)>? progress = null);
|
||||
Task<double> GetLoggedHoursAsync(DateTime? startDate = null, DateTime? endDate = null);
|
||||
}
|
||||
}
|
||||
17
BlueMine/Redmine/RedmineApiException.cs
Normal file
17
BlueMine/Redmine/RedmineApiException.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public class RedmineApiException : Exception
|
||||
{
|
||||
public int? StatusCode { get; }
|
||||
|
||||
public RedmineApiException(string message, int? statusCode = null) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public RedmineApiException(string message, Exception innerException, int? statusCode = null) : base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
BlueMine/Redmine/RedmineAuthHandler.cs
Normal file
51
BlueMine/Redmine/RedmineAuthHandler.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public class RedmineAuthHandler : DelegatingHandler
|
||||
{
|
||||
private readonly RedmineConfig _config;
|
||||
private readonly ILogger<RedmineAuthHandler> _logger;
|
||||
|
||||
public RedmineAuthHandler(RedmineConfig config, ILogger<RedmineAuthHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Checking config for valid options");
|
||||
if (!string.IsNullOrWhiteSpace(_config.ApiKey))
|
||||
{
|
||||
_logger.LogDebug("Refreshing API key");
|
||||
request.Headers.Remove("X-Redmine-API-Key");
|
||||
request.Headers.Add("X-Redmine-API-Key", _config.ApiKey);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_config.RedmineUrl)
|
||||
&& request.RequestUri != null)
|
||||
{
|
||||
_logger.LogDebug("Refreshing base URI");
|
||||
string baseUrlStr = _config.RedmineUrl.EndsWith("/")
|
||||
? _config.RedmineUrl
|
||||
: _config.RedmineUrl + "/";
|
||||
|
||||
var baseUri = new Uri(baseUrlStr);
|
||||
|
||||
string pathAndQuery = request.RequestUri.PathAndQuery;
|
||||
|
||||
if (pathAndQuery.StartsWith("/"))
|
||||
{
|
||||
pathAndQuery = pathAndQuery.Substring(1);
|
||||
}
|
||||
|
||||
request.RequestUri = new Uri(baseUri, pathAndQuery);
|
||||
}
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
BlueMine/Redmine/RedmineCache.cs
Normal file
64
BlueMine/Redmine/RedmineCache.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
class RedmineCache<T> : IRedmineCache<T>
|
||||
{
|
||||
private List<T> Items { get; set; } = [];
|
||||
|
||||
public DateTime LastUpdated { get; set; } = DateTime.MinValue;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan _cacheDuration;
|
||||
private readonly object _lock = new();
|
||||
public RedmineCache(TimeSpan cacheDuration, ILogger<RedmineCache<T>> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_cacheDuration = cacheDuration;
|
||||
}
|
||||
public RedmineCache(int cacheDurationSec, ILogger<RedmineCache<T>> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_cacheDuration = new TimeSpan(0, 0, cacheDurationSec);
|
||||
}
|
||||
|
||||
public void RefreshCache(List<T> newItems)
|
||||
{
|
||||
_logger.LogDebug($"Refreshing cache with {newItems.Count} items");
|
||||
lock (_lock)
|
||||
{
|
||||
Items = newItems;
|
||||
LastUpdated = DateTime.UtcNow;
|
||||
_logger.LogDebug("Cache refreshed");
|
||||
}
|
||||
}
|
||||
|
||||
public void InvalidateCache()
|
||||
{
|
||||
_logger.LogDebug("Invalidating cache");
|
||||
lock (_lock)
|
||||
{
|
||||
LastUpdated = DateTime.MinValue;
|
||||
_logger.LogDebug("Cache invalidated");
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsCacheValid()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
bool valid = DateTime.UtcNow - LastUpdated <= _cacheDuration;
|
||||
_logger.LogDebug($"Cache valid: {valid}");
|
||||
return valid;
|
||||
}
|
||||
}
|
||||
|
||||
public List<T> GetItems()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_logger.LogDebug($"Returning {Items.Count} cached items");
|
||||
return Items;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
BlueMine/Redmine/RedmineConfig.cs
Normal file
12
BlueMine/Redmine/RedmineConfig.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public class RedmineConfig
|
||||
{
|
||||
public string RedmineUrl { get; set; } = "http://redmine.example.com";
|
||||
public string ApiKey { get; set; } = "";
|
||||
public TimeSpan ProjectCacheDuration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
public TimeSpan IssueCacheDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
public int ConcurrencyLimit { get; set; } = 10;
|
||||
}
|
||||
}
|
||||
379
BlueMine/Redmine/RedmineConnect.cs
Normal file
379
BlueMine/Redmine/RedmineConnect.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
185
BlueMine/Redmine/RedmineDto.cs
Normal file
185
BlueMine/Redmine/RedmineDto.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public static class RedmineConstants
|
||||
{
|
||||
public const int ClosedStatusId = 30;
|
||||
public const int EstimatedHoursCustomFieldId = 65;
|
||||
}
|
||||
|
||||
public class RedmineDto
|
||||
{
|
||||
public class SimpleIssue
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ProjectName { get; set; }
|
||||
public string Subject { get; set; }
|
||||
public string Description { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime Updated { get; set; }
|
||||
}
|
||||
|
||||
public class SimpleProject
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Identifier { get; set; }
|
||||
}
|
||||
|
||||
public class SimpleTracker
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
public class IssueItem
|
||||
{
|
||||
public string ProjectName { get; set; }
|
||||
public string IssueName { get; set; }
|
||||
public string IssueDescription { get; set; }
|
||||
public int IssueNumber { get; set; }
|
||||
public double SpentTime { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime Updated { get; set; }
|
||||
public string LastUpdate { get
|
||||
{
|
||||
var span = DateTime.Now - Updated;
|
||||
|
||||
if (span.TotalMinutes < 1) return "épp most";
|
||||
if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} perce";
|
||||
if (span.TotalHours < 24) return $"{(int)span.TotalHours} órája";
|
||||
if (span.TotalDays < 7) return $"{(int)span.TotalDays} napja";
|
||||
if (span.TotalDays < 30) return $"{(int)(span.TotalDays / 7)} hete";
|
||||
if (span.TotalDays < 365) return $"{(int)(span.TotalDays / 30)} hónapja";
|
||||
|
||||
return $"{(int)(span.TotalDays / 365)} éve";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class TimeEntryRequest
|
||||
{
|
||||
[JsonPropertyName("time_entry")]
|
||||
public TimeEntry TimeEntry { get; set; }
|
||||
}
|
||||
|
||||
public class TimeEntry
|
||||
{
|
||||
[JsonPropertyName("issue_id")]
|
||||
public int IssueId { get; set; }
|
||||
[JsonPropertyName("hours")]
|
||||
public double Hours { get; set; }
|
||||
[JsonPropertyName("comments")]
|
||||
public string Comments { get; set; }
|
||||
[JsonPropertyName("spent_on")]
|
||||
public string SpentOn { get; set; }
|
||||
[JsonPropertyName("activity_id")]
|
||||
public int? ActivityId { get; set; }
|
||||
}
|
||||
|
||||
public class IssueListResponse
|
||||
{
|
||||
[JsonPropertyName("issues")]
|
||||
public List<IssueDto> Issues { get; set; }
|
||||
[JsonPropertyName("total_count")]
|
||||
public int TotalCount { get; set; }
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; set; }
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; set; }
|
||||
}
|
||||
|
||||
public class CreateIssueResponse
|
||||
{
|
||||
[JsonPropertyName("issue")]
|
||||
public IssueDto Issue { get; set; }
|
||||
}
|
||||
|
||||
public class IssueDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
[JsonPropertyName("project")]
|
||||
public IdNameDto Project { get; set; }
|
||||
[JsonPropertyName("subject")]
|
||||
public string Subject { get; set; }
|
||||
[JsonPropertyName("assigned_to")]
|
||||
public AssignedToDto AssignedTo { get; set; }
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
[JsonPropertyName("created_on")]
|
||||
public DateTime Created { get; set; }
|
||||
[JsonPropertyName("updated_on")]
|
||||
public DateTime Updated { get; set; }
|
||||
}
|
||||
|
||||
public class IdNameDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
public class AssignedToDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
}
|
||||
|
||||
public class TimeEntryListResponse
|
||||
{
|
||||
[JsonPropertyName("time_entries")]
|
||||
public List<TimeEntryDto> TimeEntries { get; set; }
|
||||
}
|
||||
|
||||
public class TimeEntryDto
|
||||
{
|
||||
[JsonPropertyName("hours")]
|
||||
public double Hours { get; set; }
|
||||
}
|
||||
|
||||
public class ProjectListResponse
|
||||
{
|
||||
[JsonPropertyName("projects")]
|
||||
public List<ProjectDto> Projects { get; set; }
|
||||
[JsonPropertyName("total_count")]
|
||||
public int TotalCount { get; set; }
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; set; }
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; set; }
|
||||
}
|
||||
|
||||
public class ProjectDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
[JsonPropertyName("identifier")]
|
||||
public string Identifier { get; set; }
|
||||
}
|
||||
|
||||
public class ProjectWithTrackersResponse
|
||||
{
|
||||
[JsonPropertyName("project")]
|
||||
public ProjectWithTrackersDto Project { get; set; }
|
||||
}
|
||||
|
||||
public class ProjectWithTrackersDto
|
||||
{
|
||||
[JsonPropertyName("trackers")]
|
||||
public List<TrackerDto> Trackers { get; set; }
|
||||
}
|
||||
|
||||
public class TrackerDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
206
BlueMine/Redmine/RedmineManager.cs
Normal file
206
BlueMine/Redmine/RedmineManager.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
using static BlueMine.Redmine.RedmineDto;
|
||||
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public class RedmineManager : IRedmineManager
|
||||
{
|
||||
private readonly RedmineConnect _redmineConnect;
|
||||
private readonly AsyncLock _lock = new();
|
||||
private readonly RedmineCache<SimpleProject> _projectCache;
|
||||
private readonly RedmineCache<SimpleIssue> _issueCache;
|
||||
private readonly ILogger<RedmineManager> _logger;
|
||||
|
||||
public RedmineManager(HttpClient httpClient, ILoggerFactory loggerFactory, RedmineConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpClient);
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
_logger = loggerFactory.CreateLogger<RedmineManager>();
|
||||
_logger.LogDebug("Initializing RedmineManager with URL: {Url}", config.RedmineUrl);
|
||||
_redmineConnect = new RedmineConnect(httpClient, loggerFactory.CreateLogger<RedmineConnect>(), config);
|
||||
_projectCache = new(config.ProjectCacheDuration, loggerFactory.CreateLogger<RedmineCache<SimpleProject>>());
|
||||
_issueCache = new(config.IssueCacheDuration, loggerFactory.CreateLogger<RedmineCache<SimpleIssue>>());
|
||||
_logger.LogDebug("RedmineManager initialized");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the Redmine instance is available by verifying the API key.
|
||||
/// </summary>
|
||||
/// <returns>True if available, false otherwise.</returns>
|
||||
public async Task<bool> IsRedmineAvailable()
|
||||
{
|
||||
_logger.LogDebug("Checking if Redmine is available");
|
||||
try
|
||||
{
|
||||
using (await _lock.LockAsync())
|
||||
{
|
||||
await _redmineConnect.VerifyApiKey();
|
||||
}
|
||||
_logger.LogDebug("Redmine is available");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug("Redmine not available: {Message}", ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Logs time for a specific issue.
|
||||
/// </summary>
|
||||
/// <param name="issueId">The issue ID.</param>
|
||||
/// <param name="hours">Hours to log.</param>
|
||||
/// <param name="comments">Comments for the time entry.</param>
|
||||
/// <param name="date">Date of the time entry.</param>
|
||||
/// <param name="activityId">Optional activity ID.</param>
|
||||
public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime date, int? activityId = null)
|
||||
{
|
||||
_logger.LogDebug("Logging {Hours} hours for issue {IssueId} on {Date}", hours, issueId, date.ToShortDateString());
|
||||
using (await _lock.LockAsync())
|
||||
{
|
||||
await _redmineConnect.LogTimeAsync(issueId, hours, comments, date, activityId);
|
||||
}
|
||||
_logger.LogDebug("Time logged successfully");
|
||||
}
|
||||
/// <summary>
|
||||
/// Closes the specified issue.
|
||||
/// </summary>
|
||||
/// <param name="issueId">The issue ID to close.</param>
|
||||
public async Task CloseIssueAsync(int issueId)
|
||||
{
|
||||
_logger.LogDebug("Closing issue {IssueId}", issueId);
|
||||
using (await _lock.LockAsync())
|
||||
{
|
||||
await _redmineConnect.CloseIssueAsync(issueId);
|
||||
}
|
||||
_logger.LogDebug("Issue {IssueId} closed", issueId);
|
||||
}
|
||||
/// <summary>
|
||||
/// Creates a new issue in the specified project.
|
||||
/// </summary>
|
||||
/// <param name="projectId">The project ID.</param>
|
||||
/// <param name="trackerId">The tracker ID.</param>
|
||||
/// <param name="subject">Issue subject.</param>
|
||||
/// <param name="description">Optional description.</param>
|
||||
/// <param name="estimatedHours">Optional estimated hours.</param>
|
||||
/// <param name="priorityId">Optional priority ID.</param>
|
||||
/// <param name="parentIssueId">Optional parent issue ID.</param>
|
||||
/// <returns>The created issue ID.</returns>
|
||||
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);
|
||||
using (await _lock.LockAsync())
|
||||
{
|
||||
var issueId = await _redmineConnect.CreateIssueAsync(projectId, trackerId, subject, description,
|
||||
estimatedHours, priorityId, parentIssueId);
|
||||
_logger.LogDebug("Issue created with ID {IssueId}", issueId);
|
||||
return issueId;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Retrieves the list of projects, using cache if valid.
|
||||
/// </summary>
|
||||
/// <param name="limit">Maximum number of projects to fetch per request.</param>
|
||||
/// <param name="progress">Optional progress reporter.</param>
|
||||
/// <returns>List of simple projects.</returns>
|
||||
public async Task<List<SimpleProject>> GetProjectsAsync(int limit = 100, IProgress<(int, int)>? progress = null)
|
||||
{
|
||||
_logger.LogDebug("Getting projects");
|
||||
using (await _lock.LockAsync())
|
||||
{
|
||||
List<SimpleProject> projects = [];
|
||||
|
||||
if(!_projectCache.IsCacheValid())
|
||||
{
|
||||
_logger.LogDebug("Cache invalid, refreshing");
|
||||
_projectCache.RefreshCache([..(await _redmineConnect.GetProjectsAsync(limit, progress))]);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Using cached projects");
|
||||
}
|
||||
|
||||
projects = _projectCache.GetItems();
|
||||
_logger.LogDebug("Retrieved {Count} projects", projects.Count);
|
||||
return projects;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Retrieves trackers for the specified project.
|
||||
/// </summary>
|
||||
/// <param name="projectId">The project ID.</param>
|
||||
/// <param name="token">Optional cancellation token.</param>
|
||||
/// <returns>List of simple trackers.</returns>
|
||||
public async Task<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null)
|
||||
{
|
||||
_logger.LogDebug("Getting trackers for project {ProjectId}", projectId);
|
||||
try
|
||||
{
|
||||
using (await _lock.LockAsync())
|
||||
{
|
||||
var trackers = await _redmineConnect.GetTrackersAsync(projectId, token);
|
||||
_logger.LogDebug("Retrieved {Count} trackers", trackers.Count);
|
||||
return trackers;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("GetTrackersAsync cancelled");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Retrieves current issues with spent time.
|
||||
/// </summary>
|
||||
/// <param name="progress">Optional progress reporter.</param>
|
||||
/// <returns>List of issue items.</returns>
|
||||
public async Task<List<IssueItem>> GetCurrentIssuesAsync(IProgress<(int, int)>? progress = null)
|
||||
{
|
||||
_logger.LogDebug("Getting current issues");
|
||||
using (await _lock.LockAsync())
|
||||
{
|
||||
List<SimpleIssue> simpleIssues;
|
||||
if (!_issueCache.IsCacheValid())
|
||||
{
|
||||
_logger.LogDebug("Issue cache invalid, refreshing");
|
||||
simpleIssues = [.. (await _redmineConnect.GetMyIssuesAsync())];
|
||||
_issueCache.RefreshCache(simpleIssues);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Using cached issues");
|
||||
simpleIssues = _issueCache.GetItems();
|
||||
}
|
||||
var issues = await _redmineConnect.GetSpentTimeForIssuesAsync(simpleIssues, progress);
|
||||
_logger.LogDebug("Retrieved {Count} issues", issues.Count);
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Retrieves logged hours for the specified date range.
|
||||
/// </summary>
|
||||
/// <param name="startDate">Start date.</param>
|
||||
/// <param name="endDate">End date.</param>
|
||||
/// <returns>Total logged hours.</returns>
|
||||
public async Task<double> GetLoggedHoursAsync(DateTime? startDate = null, DateTime? endDate = null)
|
||||
{
|
||||
var start = DateTime.Today;
|
||||
var end = DateTime.Today;
|
||||
if (startDate.HasValue)
|
||||
start = startDate.Value;
|
||||
if(endDate.HasValue)
|
||||
end = endDate.Value;
|
||||
_logger.LogDebug("Getting logged hours from {Start} to {End}", start.ToShortDateString(), end.ToShortDateString());
|
||||
using (await _lock.LockAsync())
|
||||
{
|
||||
var hours = await _redmineConnect.GetTodaysHoursAsync(start, end);
|
||||
_logger.LogDebug("Retrieved {Hours} hours", hours);
|
||||
return hours;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user