Skip to content

Instantly share code, notes, and snippets.

@lavn0
Last active September 3, 2026 14:32
Show Gist options
  • Select an option

  • Save lavn0/36f67fed28bd495d6bb5d9e1f480c6b0 to your computer and use it in GitHub Desktop.

Select an option

Save lavn0/36f67fed28bd495d6bb5d9e1f480c6b0 to your computer and use it in GitHub Desktop.
Redmine Web API sample
Redmine Web API sample
using System.Text.Json;
namespace RedmineWorkHours
{
internal static class JsonUtility
{
public static readonly JsonSerializerOptions DefaultOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
}
}
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; }
}
}
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");
}
}
}
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);
}
}
}
}
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();
}
}
}
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