initial commit

This commit is contained in:
2025-12-10 10:59:48 +01:00
commit b3605e725f
30 changed files with 2363 additions and 0 deletions

View 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();
}
}
}
}

View File

@@ -0,0 +1,10 @@
namespace BlueMine.Redmine
{
public interface IRedmineCache<T>
{
void RefreshCache(List<T> newItems);
void InvalidateCache();
bool IsCacheValid();
List<T> GetItems();
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}
}

View 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);
}
}
}

View 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;
}
}
}
}

View 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;
}
}

View 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];
}
}
}

View 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.

View 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;
}
}
}
}