Last active
September 3, 2026 14:32
-
-
Save lavn0/36f67fed28bd495d6bb5d9e1f480c6b0 to your computer and use it in GitHub Desktop.
Redmine Web API sample
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Redmine Web API sample |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.Text.Json; | |
| namespace RedmineWorkHours | |
| { | |
| internal static class JsonUtility | |
| { | |
| public static readonly JsonSerializerOptions DefaultOptions = new JsonSerializerOptions | |
| { | |
| PropertyNameCaseInsensitive = true | |
| }; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.Collections.Generic; | |
| namespace RedmineWorkHours | |
| { | |
| internal class CurrentUserResponse | |
| { | |
| public User User { get; set; } = new User(); | |
| } | |
| internal class User | |
| { | |
| public int Id { get; set; } | |
| public string Login { get; set; } = string.Empty; | |
| public string Name { get; set; } = string.Empty; | |
| } | |
| internal class TimeEntriesResponse | |
| { | |
| public List<TimeEntry> TimeEntries { get; set; } = new List<TimeEntry>(); | |
| public int TotalCount { get; set; } | |
| public int Offset { get; set; } | |
| public int Limit { get; set; } | |
| } | |
| internal class TimeEntry | |
| { | |
| public int Id { get; set; } | |
| public decimal Hours { get; set; } | |
| public string SpentOn { get; set; } = string.Empty; | |
| public User? User { get; set; } | |
| } | |
| internal class UserSummary | |
| { | |
| public int UserId { get; set; } | |
| public string UserName { get; set; } = string.Empty; | |
| public decimal Hours { get; set; } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| using System.Linq; | |
| using System.Text; | |
| namespace RedmineWorkHours | |
| { | |
| internal static class PlantUmlUtility | |
| { | |
| public static string CreateStackedBarChart(IReadOnlyList<UserSummary> summaries) | |
| { | |
| StringBuilder sb = new StringBuilder(); | |
| sb.AppendLine("@startuml"); | |
| sb.AppendLine(); | |
| sb.AppendLine("title Redmine Work Hours"); | |
| sb.AppendLine(); | |
| if (summaries.Count == 0) | |
| { | |
| sb.AppendLine("rectangle \"No data\""); | |
| } | |
| else | |
| { | |
| decimal maxHours = summaries.Max(x => x.Hours); | |
| if (maxHours <= 0) | |
| { | |
| maxHours = 1; | |
| } | |
| foreach (UserSummary summary in summaries) | |
| { | |
| int width = Math.Max(1, (int)Math.Round(summary.Hours / maxHours * 40)); | |
| string bar = new string('#', width); | |
| string name = EscapePlantUmlText(summary.UserName); | |
| sb.AppendLine("rectangle \"" + name + "\\n" + summary.Hours.ToString("0.##") + "h\" as U" + summary.UserId); | |
| sb.AppendLine("U" + summary.UserId + " : " + bar); | |
| } | |
| } | |
| sb.AppendLine(); | |
| sb.AppendLine("@enduml"); | |
| return sb.ToString(); | |
| } | |
| private static string EscapePlantUmlText(string value) | |
| { | |
| return value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "").Replace("\n", "\\n"); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| using System.IO; | |
| using System.Text; | |
| using System.Threading.Tasks; | |
| namespace RedmineWorkHours | |
| { | |
| internal class Program | |
| { | |
| private const string RedmineUrl = "https://redmine.example.com"; | |
| private const string Username = "your_username"; | |
| private const string Password = "your_password"; | |
| private const int ProjectId = 1; | |
| private const int QueryId = 123; | |
| public static async Task Main(string[] args) | |
| { | |
| Console.WriteLine("Redmine Work Hours"); | |
| using (RedmineClient redmine = new RedmineClient(RedmineUrl, Username, Password)) | |
| { | |
| Console.WriteLine("Checking authentication..."); | |
| CurrentUserResponse currentUser = await redmine.GetCurrentUserAsync(); | |
| Console.WriteLine("Authenticated user: " + currentUser.User.Login + " (id=" + currentUser.User.Id + ")"); | |
| Console.WriteLine(); | |
| Console.WriteLine("Getting time entries..."); | |
| List<TimeEntry> timeEntries = await redmine.GetTimeEntriesAsync(ProjectId); | |
| Console.WriteLine("Time entries: " + timeEntries.Count); | |
| List<UserSummary> summaries = TimeEntryUtility.CreateUserSummaries(timeEntries); | |
| Console.WriteLine(); | |
| Console.WriteLine("Summary"); | |
| foreach (UserSummary summary in summaries) | |
| { | |
| Console.WriteLine(summary.UserName + ": " + summary.Hours.ToString("0.##") + "h"); | |
| } | |
| string plantUml = PlantUmlUtility.CreateStackedBarChart(summaries); | |
| string outputPath = Path.Combine(Environment.CurrentDirectory, "work-hours.puml"); | |
| await File.WriteAllTextAsync(outputPath, plantUml, Encoding.UTF8); | |
| Console.WriteLine(); | |
| Console.WriteLine("PlantUML file created: " + outputPath); | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| using System.Net; | |
| using System.Net.Http; | |
| using System.Net.Http.Headers; | |
| using System.Text; | |
| using System.Text.Json; | |
| using System.Threading.Tasks; | |
| namespace RedmineWorkHours | |
| { | |
| internal class RedmineClient : IDisposable | |
| { | |
| private readonly HttpClient _client; | |
| public RedmineClient(string redmineUrl, string username, string password) | |
| { | |
| HttpClientHandler handler = new HttpClientHandler | |
| { | |
| CookieContainer = new CookieContainer(), | |
| UseCookies = true, | |
| AllowAutoRedirect = true | |
| }; | |
| _client = new HttpClient(handler) { BaseAddress = new Uri(redmineUrl) }; | |
| _client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); | |
| string credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + password)); | |
| _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); | |
| } | |
| public async Task<CurrentUserResponse> GetCurrentUserAsync() | |
| { | |
| const string url = "/users/current.json"; | |
| Console.WriteLine("GET " + url); | |
| using (HttpResponseMessage response = await _client.GetAsync(url)) | |
| { | |
| string body = await response.Content.ReadAsStringAsync(); | |
| if (!response.IsSuccessStatusCode) | |
| { | |
| throw new Exception("Redmine API authentication failed. HTTP " + (int)response.StatusCode + " " + response.StatusCode + Environment.NewLine + body); | |
| } | |
| CurrentUserResponse? result = JsonSerializer.Deserialize<CurrentUserResponse>(body, JsonUtility.DefaultOptions); | |
| if (result == null || result.User == null) | |
| { | |
| throw new Exception("Invalid response from /users/current.json."); | |
| } | |
| return result; | |
| } | |
| } | |
| public async Task<List<TimeEntry>> GetTimeEntriesAsync(int projectId) | |
| { | |
| List<TimeEntry> result = new List<TimeEntry>(); | |
| int offset = 0; | |
| const int limit = 100; | |
| while (true) | |
| { | |
| string url = "/time_entries.json?project_id=" + projectId + "&offset=" + offset + "&limit=" + limit; | |
| Console.WriteLine("GET " + url); | |
| using (HttpResponseMessage response = await _client.GetAsync(url)) | |
| { | |
| string body = await response.Content.ReadAsStringAsync(); | |
| if (!response.IsSuccessStatusCode) | |
| { | |
| throw new Exception("Failed to get time entries. HTTP " + (int)response.StatusCode + " " + response.StatusCode + Environment.NewLine + body); | |
| } | |
| TimeEntriesResponse? data = JsonSerializer.Deserialize<TimeEntriesResponse>(body, JsonUtility.DefaultOptions); | |
| if (data == null || data.TimeEntries == null) | |
| { | |
| break; | |
| } | |
| result.AddRange(data.TimeEntries); | |
| if (result.Count >= data.TotalCount) | |
| { | |
| break; | |
| } | |
| offset += limit; | |
| } | |
| } | |
| return result; | |
| } | |
| public void Dispose() | |
| { | |
| _client.Dispose(); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.Collections.Generic; | |
| using System.Linq; | |
| namespace RedmineWorkHours | |
| { | |
| internal static class TimeEntryUtility | |
| { | |
| public static List<UserSummary> CreateUserSummaries(IEnumerable<TimeEntry> timeEntries) | |
| { | |
| return timeEntries | |
| .Where(x => x.User != null) | |
| .GroupBy(x => new { Id = x.User!.Id, Name = x.User.Name }) | |
| .Select(g => new UserSummary | |
| { | |
| UserId = g.Key.Id, | |
| UserName = g.Key.Name, | |
| Hours = g.Sum(x => x.Hours) | |
| }) | |
| .OrderByDescending(x => x.Hours) | |
| .ToList(); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment