Files
ClaudeDo/src/ClaudeDo.Worker/Report/WeekReportService.cs
T
mika kuns 296251e27e refactor: collapse single-implementation interfaces
Nine interfaces had exactly one implementation and no test double — they existed
only to be named twice in a DI registration: IFindingsStore, IFindingsStoreLocator,
IPrimeScheduleSignal, IRefineRunner, IWeekReportService, IMergeCoordinator,
IMissionControlPane, IOnlineLoginService, ITaskListFilter. Consumers now depend on
the concrete type; the DTO records that shared those files moved next to their
implementation. IInteractiveLaunchSpecService stays — it carries 54 lines of
contract documentation, which is not ceremony.

IMergeCoordinator in particular had a redundant null object: MergeCoordinator with
a null Handler already no-ops, and every test used the real class with Handler set.

Filtering/ collapses from 8 files to 1. ITaskListFilter and TaskListFilterBase were
a double abstraction over four predicates, with MatchesAsContext => false declared
in both. SmartFlagFilter also compiled its expression twice (its own _flag plus the
inherited Matches cache) — it now uses the cache.

StaticTokenAuthProvider was in src but production uses ZitadelAuthProvider; it is
a test double, so it moves to the test project. Its own test goes away with it.
2026-08-26 10:12:23 +02:00

101 lines
3.9 KiB
C#

using System.Text.Json;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Report;
public sealed class WeekReportService
{
private static readonly string[] DefaultExcludes = { @"C:\Private" };
private const string NoActivity = "_No activity in this period._";
private readonly IClaudeHistoryReader _reader;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IClaudeProcess _claude;
private readonly ILogger<WeekReportService> _logger;
public WeekReportService(
IClaudeHistoryReader reader,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IClaudeProcess claude,
ILogger<WeekReportService> logger)
{
_reader = reader;
_dbFactory = dbFactory;
_claude = claude;
_logger = logger;
}
public async Task<string?> GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var row = await new WeekReportRepository(ctx).GetByRangeAsync(start, end, ct);
return row?.Markdown;
}
public async Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
{
string[] excludes;
string model;
IReadOnlyList<Data.Models.DailyNoteEntity> noteRows;
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
{
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
excludes = ParseExcludes(settings.ReportExcludedPaths);
model = string.IsNullOrWhiteSpace(settings.DefaultModel) ? "sonnet" : settings.DefaultModel;
noteRows = await new DailyNoteRepository(ctx).ListBetweenAsync(start, end, ct);
}
var activity = await _reader.ReadAsync(start, end, excludes, ct);
var notesByDay = noteRows
.GroupBy(n => n.Date)
.ToDictionary(g => g.Key, g => g.Select(n => n.Text).ToList());
string markdown;
var hasActivity = activity.Any(r => r.Days.Any(d => d.Prompts.Count > 0 || d.Summaries.Count > 0));
if (!hasActivity && notesByDay.Count == 0)
{
markdown = NoActivity;
}
else
{
var prompt = WeekReportPromptBuilder.Build(start, end, activity, notesByDay);
// Guard against argument injection via the model setting: model aliases/ids are
// alphanumerics, dashes and dots only.
var safeModel = new string(model.Where(c => char.IsLetterOrDigit(c) || c is '-' or '.').ToArray());
if (safeModel.Length == 0) safeModel = "sonnet";
IReadOnlyList<string> args =
[
"-p", "--output-format", "stream-json", "--verbose",
"--permission-mode", "auto",
"--model", safeModel,
];
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
if (!result.IsSuccess)
throw new InvalidOperationException(result.ErrorMarkdown ?? "Claude could not generate the report.");
markdown = result.ResultMarkdown!;
}
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
await new WeekReportRepository(ctx).UpsertAsync(start, end, markdown, ct);
return markdown;
}
private static string[] ParseExcludes(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return DefaultExcludes;
try
{
var list = JsonSerializer.Deserialize<List<string>>(json);
return list is { Count: > 0 } ? list.ToArray() : DefaultExcludes;
}
catch (JsonException) { return DefaultExcludes; }
}
}