Skip to content

Commit 40f47bf

Browse files
AdrianDeutschclaude
andcommitted
feat: multi-channel drift alerts (GitHub issues) + cross-package drift digest
Two extensions that span Application/Infrastructure/Presentation without touching Domain. - GitHub-issue channel (Infrastructure): GitHubIssueDriftNotifier implements the existing IDriftNotifier port and opens an issue via POST /repos/{owner}/{repo}/issues; title/body from a pure DriftIssue formatter (Application). - Fan-out, not a switch (Infrastructure): CompositeDriftNotifier composes ALL configured channels; AddInfrastructure builds the list from Alerts:SlackWebhookUrl + Alerts:GitHubRepo (NullDriftNotifier when none). Adding a channel is one class + one registration — RunScanHandler is unchanged. Hosts pass Alerts:GitHubRepo. - Drift digest (Application + Presentation): DriftDigestBuilder + GetDriftDigestQuery render one Markdown report of drift across every tracked root (reusing GetTrackedRootsAsync + DriftAnalyzer); served at GET /api/drift/digest. No new persistence. - Tests (81 green): DriftDigestBuilder + DriftIssue unit tests; an integration test for the digest over real Postgres. ADR 0012, README (features, multi-channel config, digest curl). Verified live: digest renders the full drift report for a seeded baseline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0d92940 commit 40f47bf

16 files changed

Lines changed: 411 additions & 15 deletions

File tree

README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ has: **"Is this upgrade worth it — and how risky is it?"**
7070
- 📈 **Drift over time** — every scan is snapshotted, so the dashboard shows what *rotted
7171
since you last looked*: a dependency that newly became vulnerable, deprecated or archived.
7272
- 🔔 **Autonomous monitoring** — an opt-in watchlist re-scans tracked packages on a schedule
73-
and **alerts a Slack webhook** the moment a new high-severity CVE (or deprecation) lands.
73+
and, the moment a new high-severity CVE (or deprecation) lands, **alerts Slack and/or opens
74+
a GitHub issue** (pluggable, multi-channel).
75+
- 📰 **Drift digest** — a single Markdown report of what changed across *every* tracked
76+
package since the last scan (a daily summary you can pipe anywhere).
7477
- 🏷️ **Health badge** — a shields-style `badge.svg` per package to drop into any README.
7578
- 🧰 **CLI / CI gate**`depradar scan` as a `dotnet tool` runs the **whole analysis
7679
standalone** (no server, no database) and **fails the build** on policy violations.
@@ -215,6 +218,9 @@ curl http://localhost:<api-port>/api/packages/WindowsAzure.Storage/drift
215218

216219
# A shields-style health badge (embed in a README)
217220
curl http://localhost:<api-port>/api/packages/Serilog.Sinks.Console/badge.svg
221+
222+
# A Markdown drift digest across every tracked package
223+
curl http://localhost:<api-port>/api/drift/digest
218224
```
219225

220226
### Health badge
@@ -232,12 +238,16 @@ Set two config values and DepRadar watches your dependencies for you:
232238
```jsonc
233239
// appsettings / environment / Aspire parameters
234240
"Watch": { "IntervalHours": 24 }, // re-scan every tracked package daily
235-
"Alerts": { "SlackWebhookUrl": "https://hooks.slack.com/services/…" }
241+
"Alerts": {
242+
"SlackWebhookUrl": "https://hooks.slack.com/services/…", // optional channel
243+
"GitHubRepo": "owner/name" // optional channel (uses GitHub:Token)
244+
}
236245
```
237246

238247
The worker re-scans every previously-scanned package on the interval; when a re-scan
239-
introduces a **new high-severity** issue (CVE, deprecation, archival) it posts a drift
240-
alert to the Slack webhook. Both are off by default — no webhook, no schedule, no noise.
248+
introduces a **new high-severity** issue (CVE, deprecation, archival) it fans the alert out
249+
to **every configured channel** — Slack, a GitHub issue, or both. Everything is off by
250+
default: no webhook, no repo, no schedule, no noise.
241251

242252
### CLI — scan and gate a build, with no server or database
243253

@@ -377,6 +387,9 @@ dotnet test # unit + architecture + integration (needs Docker)
377387
- [x] **Autonomous monitoring:** bounded snapshot retention, an opt-in **watchlist** that
378388
re-scans on a schedule, **Slack drift alerts** on new high-severity issues, and a
379389
shields-style **health badge** ([ADR 0011]).
390+
- [x] **Multi-channel alerts & digest:** a pluggable notifier that also **opens GitHub
391+
issues** (fan-out to every configured channel), and a Markdown **drift digest**
392+
across all tracked packages ([ADR 0012]).
380393

381394
## License & credits
382395

@@ -393,3 +406,4 @@ Data sources: [NuGet V3 API](https://api.nuget.org/v3/index.json) ·
393406
[ADR 0009]: docs/adr/0009-stateless-analysis-cli-and-policy.md
394407
[ADR 0010]: docs/adr/0010-scan-history-and-drift.md
395408
[ADR 0011]: docs/adr/0011-autonomous-monitoring-and-badge.md
409+
[ADR 0012]: docs/adr/0012-multi-channel-alerts-and-digest.md
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# ADR 0012 — Multi-channel drift alerts & digest
2+
3+
- Status: Accepted
4+
- Date: 2026-06-26
5+
- Deciders: Architecture
6+
7+
## Context
8+
9+
Drift alerts ([ADR 0011]) shipped with one channel (Slack). Teams triage in different
10+
places — some want a **GitHub issue** instead of (or alongside) a Slack ping. And beyond
11+
event-by-event alerts, a periodic **digest** ("everything that changed across all my
12+
packages") is its own useful artifact. Both were asked for; both should fit the existing
13+
seams without reshaping the domain — a good test of the architecture's extensibility.
14+
15+
This is also the answer to "can we keep extending the Application, Infrastructure and
16+
Presentation layers?": yes — each addition lands cleanly in one or more of them while the
17+
Domain (the risk + drift model) stays untouched.
18+
19+
## Decision
20+
21+
- **A GitHub-issue channel** (`GitHubIssueDriftNotifier`, **Infrastructure**) implements
22+
the existing `IDriftNotifier` port and opens an issue via the REST API
23+
(`POST /repos/{owner}/{repo}/issues`). Issue title/body come from a pure `DriftIssue`
24+
formatter (**Application**), so wording is unit-tested without the HTTP client.
25+
- **Fan-out, not a switch** (`CompositeDriftNotifier`, **Infrastructure**): the registered
26+
`IDriftNotifier` is composed from *all* configured channels. `AddInfrastructure` builds
27+
the list from config (`Alerts:SlackWebhookUrl`, `Alerts:GitHubRepo`) and falls back to
28+
the no-op notifier when none are set. Adding a third channel later is one class + one
29+
registration — `RunScanHandler` never changes.
30+
- **Drift digest** (`DriftDigestBuilder` + `GetDriftDigestQuery/Handler`, **Application**):
31+
computes drift for every tracked root (reusing `GetTrackedRootsAsync` and the
32+
`DriftAnalyzer`) and renders one Markdown report, served at `GET /api/drift/digest`
33+
(**Presentation**). No new persistence — it composes parts that already exist.
34+
35+
## Consequences
36+
37+
- The notification side is now genuinely pluggable; channels are independent and
38+
best-effort (one flaky channel never blocks the others, and never fails a scan).
39+
- Each feature demonstrably spans the layers it should and **adds nothing to the Domain**,
40+
which is the point of the dependency-inversion boundaries.
41+
- GitHub issues are created per drift (no de-dup/labels yet); with the opt-in, interval-
42+
bounded watchlist that is at most one issue per package per interval — refinement
43+
(find-or-update an open issue) is noted as a follow-up.
44+
45+
[ADR 0011]: 0011-autonomous-monitoring-and-badge.md
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using DepRadar.Application.History;
2+
using DepRadar.Application.Messaging;
3+
4+
namespace DepRadar.Api.Endpoints;
5+
6+
/// <summary>Cross-package drift endpoints (not scoped to a single package id).</summary>
7+
internal static class DriftEndpoints
8+
{
9+
/// <summary>Registers the <c>/api/drift</c> endpoint group.</summary>
10+
public static IEndpointRouteBuilder MapDriftEndpoints(this IEndpointRouteBuilder app)
11+
{
12+
app.MapGet("/api/drift/digest", GetDigestAsync)
13+
.WithTags("Drift")
14+
.WithName("GetDriftDigest")
15+
.WithSummary("A Markdown digest of what changed across every tracked package since the previous scan.")
16+
.Produces(StatusCodes.Status200OK, contentType: "text/markdown");
17+
18+
return app;
19+
}
20+
21+
private static async Task<IResult> GetDigestAsync(ISender sender, CancellationToken cancellationToken)
22+
{
23+
var markdown = await sender.Send(new GetDriftDigestQuery(), cancellationToken);
24+
return Results.Text(markdown, "text/markdown");
25+
}
26+
}

src/DepRadar.Api/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
builder.Configuration["Anthropic:ApiKey"],
3131
builder.Configuration["Anthropic:Model"],
3232
builder.Configuration["GitHub:Token"],
33-
builder.Configuration["Alerts:SlackWebhookUrl"]);
33+
builder.Configuration["Alerts:SlackWebhookUrl"],
34+
builder.Configuration["Alerts:GitHubRepo"]);
3435

3536
builder.Services.AddOpenApi();
3637
builder.Services.AddProblemDetails();
@@ -69,6 +70,7 @@
6970
app.MapPackageEndpoints();
7071
app.MapScanEndpoints();
7172
app.MapProjectEndpoints();
73+
app.MapDriftEndpoints();
7274
app.MapHub<ScanHub>("/hubs/scan");
7375

7476
await app.RunAsync();
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using DepRadar.Domain.History;
4+
5+
namespace DepRadar.Application.History;
6+
7+
/// <summary>
8+
/// Renders a Markdown drift digest across every tracked package — a single, shareable
9+
/// "what changed in my dependencies" report (e.g. a daily summary).
10+
/// </summary>
11+
public static class DriftDigestBuilder
12+
{
13+
/// <summary>Builds the digest from per-root drift reports, taken at <paramref name="generatedAt"/>.</summary>
14+
public static string Render(IReadOnlyList<DriftReport> reports, DateTimeOffset generatedAt)
15+
{
16+
var withDrift = reports
17+
.Where(r => r.Events.Count > 0)
18+
.OrderBy(r => r.NetHealthDelta) // worst (most negative) first
19+
.ToList();
20+
21+
var builder = new StringBuilder();
22+
builder.AppendLine("# DepRadar drift digest").AppendLine();
23+
builder.Append("_Generated ")
24+
.Append(generatedAt.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture))
25+
.Append(" · ")
26+
.Append(reports.Count.ToString(CultureInfo.InvariantCulture))
27+
.AppendLine(" package(s) tracked_").AppendLine();
28+
29+
if (withDrift.Count == 0)
30+
{
31+
builder.AppendLine("No drift detected since the previous scan. :tada:");
32+
return builder.ToString();
33+
}
34+
35+
foreach (var report in withDrift)
36+
{
37+
var delta = report.NetHealthDelta.ToString("+0;-0;0", CultureInfo.InvariantCulture);
38+
builder.Append("## ").Append(report.Root.Value).Append(" (net health ").Append(delta).AppendLine(")").AppendLine();
39+
40+
foreach (var change in report.Events)
41+
{
42+
builder.Append("- **").Append(change.Package).Append("** ")
43+
.Append(change.Kind).Append(": ").AppendLine(change.Detail);
44+
}
45+
46+
builder.AppendLine();
47+
}
48+
49+
var unchanged = reports.Count - withDrift.Count;
50+
if (unchanged > 0)
51+
{
52+
builder.Append('_').Append(unchanged.ToString(CultureInfo.InvariantCulture)).AppendLine(" package(s) unchanged._");
53+
}
54+
55+
return builder.ToString();
56+
}
57+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using DepRadar.Domain.History;
4+
5+
namespace DepRadar.Application.History;
6+
7+
/// <summary>
8+
/// Formats an actionable drift report into a GitHub issue (title + Markdown body).
9+
/// Pure, so the wording is testable independently of the GitHub client.
10+
/// </summary>
11+
public static class DriftIssue
12+
{
13+
/// <summary>The issue title.</summary>
14+
public static string Title(DriftReport report) =>
15+
string.Create(
16+
CultureInfo.InvariantCulture,
17+
$"DepRadar: drift in {report.Root.Value} ({DriftAlert.Actionable(report).Count} new high-severity issue(s))");
18+
19+
/// <summary>The issue body in GitHub-flavored Markdown.</summary>
20+
public static string Body(DriftReport report)
21+
{
22+
var delta = report.NetHealthDelta.ToString("+0;-0;0", CultureInfo.InvariantCulture);
23+
24+
var builder = new StringBuilder();
25+
builder.Append("DepRadar detected drift in `").Append(report.Root.Value)
26+
.Append("` since the previous scan (net health ").Append(delta).AppendLine(").").AppendLine();
27+
builder.AppendLine("**New high-severity issues:**").AppendLine();
28+
29+
foreach (var change in DriftAlert.Actionable(report))
30+
{
31+
builder.Append("- **").Append(change.Package).Append("** — ").AppendLine(change.Detail);
32+
}
33+
34+
builder.AppendLine().AppendLine("_Reported automatically by DepRadar._");
35+
return builder.ToString();
36+
}
37+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using DepRadar.Application.Abstractions;
2+
using DepRadar.Application.Messaging;
3+
using DepRadar.Domain.History;
4+
5+
namespace DepRadar.Application.History;
6+
7+
/// <summary>
8+
/// Handles <see cref="GetDriftDigestQuery"/>: computes drift for every tracked root and
9+
/// renders the combined Markdown digest.
10+
/// </summary>
11+
public sealed class GetDriftDigestHandler(IScanSnapshotRepository snapshots, TimeProvider timeProvider)
12+
: IRequestHandler<GetDriftDigestQuery, string>
13+
{
14+
/// <inheritdoc />
15+
public async Task<string> Handle(GetDriftDigestQuery request, CancellationToken cancellationToken)
16+
{
17+
var roots = await snapshots.GetTrackedRootsAsync(cancellationToken);
18+
19+
var reports = new List<DriftReport>();
20+
foreach (var root in roots)
21+
{
22+
var recent = await snapshots.GetRecentAsync(root, 2, cancellationToken);
23+
if (recent.Count == 2)
24+
{
25+
reports.Add(DriftAnalyzer.Compare(recent[1], recent[0]));
26+
}
27+
}
28+
29+
return DriftDigestBuilder.Render(reports, timeProvider.GetUtcNow());
30+
}
31+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using DepRadar.Application.Messaging;
2+
3+
namespace DepRadar.Application.History;
4+
5+
/// <summary>
6+
/// Query: a Markdown drift digest across every tracked package (those with at least
7+
/// two scans). Always returns Markdown — an empty digest when nothing has drifted.
8+
/// </summary>
9+
public sealed record GetDriftDigestQuery : IRequest<string>;

src/DepRadar.Infrastructure/DependencyInjection.cs

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public static class DependencyInjection
4040
/// <param name="anthropicModel">Anthropic model id (defaults to a current Claude model).</param>
4141
/// <param name="gitHubToken">GitHub token (optional) to raise the repo-health API rate limit.</param>
4242
/// <param name="slackWebhookUrl">Slack incoming-webhook URL (optional) for drift alerts.</param>
43+
/// <param name="gitHubAlertRepo">GitHub <c>owner/name</c> (optional) to open drift-alert issues in.</param>
4344
public static IServiceCollection AddInfrastructure(
4445
this IServiceCollection services,
4546
string? depsDevBaseUrl = null,
@@ -48,7 +49,8 @@ public static IServiceCollection AddInfrastructure(
4849
string? anthropicApiKey = null,
4950
string? anthropicModel = null,
5051
string? gitHubToken = null,
51-
string? slackWebhookUrl = null)
52+
string? slackWebhookUrl = null,
53+
string? gitHubAlertRepo = null)
5254
{
5355
// Caches external API responses (NuGet/OSV/deps.dev) so repeated scans don't
5456
// burn quota; an idempotent re-scan hits the cache, not the network.
@@ -67,7 +69,7 @@ public static IServiceCollection AddInfrastructure(
6769
services.AddScoped<IUnitOfWork>(provider => provider.GetRequiredService<DepRadarDbContext>());
6870

6971
AddLanguageModel(services, anthropicApiKey, anthropicModel);
70-
AddDriftNotifier(services, slackWebhookUrl);
72+
AddDriftNotifier(services, slackWebhookUrl, gitHubToken, gitHubAlertRepo);
7173

7274
services.AddHttpClient<IPackageMetadataSource, DepsDevPackageMetadataSource>(client =>
7375
{
@@ -129,17 +131,58 @@ public static IServiceCollection AddDepRadarDbContext(this IServiceCollection se
129131

130132
// Wires Claude when an API key is present; otherwise a null model so the upgrade
131133
// advisor falls back to a deterministic templated narrative (works keyless).
132-
/// <summary>Wires the Slack drift webhook when configured, else a no-op notifier.</summary>
133-
private static void AddDriftNotifier(IServiceCollection services, string? slackWebhookUrl)
134+
/// <summary>
135+
/// Composes the configured drift channels (Slack webhook, GitHub issues). With none
136+
/// configured it falls back to a no-op notifier — alerting stays opt-in.
137+
/// </summary>
138+
private static void AddDriftNotifier(IServiceCollection services, string? slackWebhookUrl, string? gitHubToken, string? gitHubAlertRepo)
134139
{
135-
if (string.IsNullOrWhiteSpace(slackWebhookUrl))
140+
var hasSlack = !string.IsNullOrWhiteSpace(slackWebhookUrl);
141+
var hasGitHub = !string.IsNullOrWhiteSpace(gitHubAlertRepo);
142+
143+
if (hasSlack)
136144
{
137-
services.AddScoped<IDriftNotifier, NullDriftNotifier>();
138-
return;
145+
services.AddHttpClient<SlackDriftNotifier>(client => client.BaseAddress = new Uri(slackWebhookUrl!))
146+
.AddStandardResilienceHandler();
139147
}
140148

141-
services.AddHttpClient<IDriftNotifier, SlackDriftNotifier>(client => client.BaseAddress = new Uri(slackWebhookUrl))
142-
.AddStandardResilienceHandler();
149+
if (hasGitHub)
150+
{
151+
services.AddSingleton(new GitHubAlertOptions(gitHubAlertRepo!));
152+
services.AddHttpClient<GitHubIssueDriftNotifier>(client =>
153+
{
154+
client.BaseAddress = new Uri("https://api.github.com/");
155+
client.DefaultRequestHeaders.UserAgent.ParseAdd("DepRadar");
156+
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
157+
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
158+
if (!string.IsNullOrWhiteSpace(gitHubToken))
159+
{
160+
client.DefaultRequestHeaders.Authorization = new("Bearer", gitHubToken);
161+
}
162+
})
163+
.AddStandardResilienceHandler();
164+
}
165+
166+
services.AddScoped<IDriftNotifier>(provider =>
167+
{
168+
var channels = new List<IDriftNotifier>();
169+
if (hasSlack)
170+
{
171+
channels.Add(provider.GetRequiredService<SlackDriftNotifier>());
172+
}
173+
174+
if (hasGitHub)
175+
{
176+
channels.Add(provider.GetRequiredService<GitHubIssueDriftNotifier>());
177+
}
178+
179+
return channels.Count switch
180+
{
181+
0 => new NullDriftNotifier(),
182+
1 => channels[0],
183+
_ => new CompositeDriftNotifier(channels),
184+
};
185+
});
143186
}
144187

145188
private static void AddLanguageModel(IServiceCollection services, string? apiKey, string? model)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using DepRadar.Application.History;
2+
using DepRadar.Domain.History;
3+
4+
namespace DepRadar.Infrastructure.Notifications;
5+
6+
/// <summary>
7+
/// Fans a drift alert out to every configured channel (Slack, GitHub, …). Each channel
8+
/// is attempted; the aggregate is awaited so one flaky channel never blocks the others.
9+
/// </summary>
10+
internal sealed class CompositeDriftNotifier(IReadOnlyList<IDriftNotifier> channels) : IDriftNotifier
11+
{
12+
/// <inheritdoc />
13+
public Task NotifyAsync(DriftReport report, CancellationToken cancellationToken) =>
14+
Task.WhenAll(channels.Select(channel => channel.NotifyAsync(report, cancellationToken)));
15+
}

0 commit comments

Comments
 (0)