Skip to content

feat: add opt-in publish to understand-quickly registry#361

Open
amacsmith wants to merge 2 commits intoAIDotNet:mainfrom
amacsmith:feat/uq-publish
Open

feat: add opt-in publish to understand-quickly registry#361
amacsmith wants to merge 2 commits intoAIDotNet:mainfrom
amacsmith:feat/uq-publish

Conversation

@amacsmith
Copy link
Copy Markdown

@amacsmith amacsmith commented May 10, 2026

Why

looptech-ai/understand-quickly is a public registry of code-knowledge graphs that ships an MCP server and a stable registry.json API. OpenDeepWiki already runs graphify and produces a graphify-out/graph.json per repo via GraphifyCliRunner. That artifact is exactly the producer shape the registry is designed for — wiring an opt-in publish step means every OpenDeepWiki deployment can land its generated graphs in the registry, and AI agents (Claude, Codex, Cursor via MCP) can discover and consume them immediately without each user pointing at a private OpenDeepWiki endpoint.

  • Discoverability. Every published graph appears at https://looptech-ai.github.io/understand-quickly/.
  • No infrastructure. Graphs stay in the user's repo; the registry only stores pointers and fetches from raw.githubusercontent.com.
  • Agent-consumable. Schema validation, drift detection (when metadata.commit is set), and a turnkey MCP server.

What changes

  • A new UnderstandQuicklyPublisher service in Services/Graphify/ that, given a GraphifyRunResult:
    1. Idempotently merges metadata.{tool, tool_version, generated_at, commit} into the graph.json produced by GraphifyCliRunner.
    2. When a token is configured, fires a repository_dispatch event at looptech-ai/understand-quickly so the registry's sync workflow re-pulls the entry.
  • A small hook in GraphifyArtifactWorker.ProcessArtifactAsync that calls the publisher after a successful run. Failures are logged but never fail the artifact — the local graph.json is always written first.
  • DI registration in Program.cs with both an appsettings.json UnderstandQuickly: section and an UNDERSTAND_QUICKLY_TOKEN env-var override (mirrors the existing GRAPHIFY_* env-var pattern).
  • Three xunit tests using Moq.Protected for the HttpMessageHandler stub, matching the existing GraphifyCliRunnerTests style.
  • One new env var documented in the README.

Diff stats

 README.md                                                            |   1 +
 src/OpenDeepWiki/Program.cs                                          |  15 ++
 src/OpenDeepWiki/Services/Graphify/GraphifyArtifactWorker.cs         |  24 +++
 src/OpenDeepWiki/Services/Graphify/UnderstandQuicklyPublisher.cs     | 163 ++++++++
 tests/OpenDeepWiki.Tests/Services/Graphify/UnderstandQuicklyPublisherTests.cs | 124 +++++
 5 files changed, 327 insertions(+)

The publisher itself is 163 LoC; the rest is tests + docs + DI wiring.

No-op default

The service is opt-in:

  • UnderstandQuickly:Enabled defaults to false.
  • The publisher's first check returns (MetadataStamped: false, Dispatched: false) immediately when disabled — no metadata mutation, no network call.
  • Even when enabled, an unset Token only stamps the local file (no dispatch).
  • Even when both are set, the existing artifact-generation flow is unchanged: the publisher runs after runner.GenerateAsync succeeds and artifact.Status = Completed is recorded. Failures inside the publisher are caught and logged.

Token setup

Fine-grained GitHub PAT, single permission:

  • Repository access: looptech-ai/understand-quickly only.
  • Permissions: Repository dispatches: write. Nothing else.

Set via appsettings.json:

{
  "UnderstandQuickly": {
    "Enabled": true,
    "Token": "ghp_..."
  }
}

…or via environment:

UNDERSTAND_QUICKLY_TOKEN=ghp_... dotnet run --project src/OpenDeepWiki

For environments running OpenDeepWiki in CI, the looptech-ai/uq-publish-action@v0.1.0 Marketplace Action is available as the source-side fallback (collapses the dispatch step to ~5 lines of YAML).

Test plan

  • UnderstandQuicklyPublisherTests.PublishAsync_Disabled_NoOpAndNoStamp — disabled service does not touch the file or call HTTP.
  • UnderstandQuicklyPublisherTests.PublishAsync_EnabledNoToken_StampsButDoesNotDispatch — metadata is stamped; HTTP is not called (verified via Moq throwing if invoked).
  • UnderstandQuicklyPublisherTests.PublishAsync_EnabledWithToken_DispatchesAndReportsSuccess — verifies request URL, Bearer auth, payload structure, and the event_type=uq-publish shape.
  • (Maintainer) UNDERSTAND_QUICKLY_TOKEN=… dotnet test — verify the new tests pass in your CI environment (we don't have .NET 10 available in our test sandbox).
  • (Maintainer) End-to-end: trigger a Graphify generation with the env var set against a registered registry entry and confirm the registry's sync.yml runs within ~1 minute.

Schema fit

graphify-out/graph.json (in node_link_data shape with nodes and links arrays) maps cleanly to the registry's gitnexus@1 schema. Drift detection works as long as metadata.commit is populated — which the publisher sets from result.CommitId (already populated by GraphifyCliRunner).

Notes

  • This is opt-in for early adopters; nothing in OpenDeepWiki's existing surface needs to break.
  • Once a few users land in the registry, we can add AIDotNet/OpenDeepWiki to the verified-publisher allowlist for auto-merge of registry updates.
  • Licensing for users. Submitting via --publish is governed by the Understand-Quickly Data License 1.0. It is opt-in, gated on the user explicitly setting the token.

Links


Current live state (sweep 2026-05-11)

The registry and its publishing surface are tagged and live:

Nothing in this PR depends on any pre-release surface — all referenced artifacts are pinned versions.

OpenDeepWiki already produces a graphify-out/graph.json artifact via
GraphifyCliRunner. This PR adds a small UnderstandQuicklyPublisher
service that, after a successful run, stamps
metadata.{tool, tool_version, generated_at, commit} into the JSON and
— when UNDERSTAND_QUICKLY_TOKEN is set — fires a
repository_dispatch event at looptech-ai/understand-quickly so the
public registry of code-knowledge graphs picks up the entry.

The service is opt-in (Enabled=false by default; UnderstandQuickly:Token
config or UNDERSTAND_QUICKLY_TOKEN env var enables it). Network
failures are logged but never fail the artifact — the local graph.json
is always written first.

Three xunit tests covering the disabled, no-token, and dispatched
paths. Uses Moq.Protected for the HttpMessageHandler stub, matching
the existing GraphifyCliRunnerTests style.

Spec: https://github.com/looptech-ai/understand-quickly/blob/main/docs/spec/code-graph-protocol.md
Action: https://github.com/looptech-ai/uq-publish-action
Copilot AI review requested due to automatic review settings May 10, 2026 07:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in integration that stamps registry metadata into Graphify’s generated graph.json and (when configured with a GitHub token) triggers a repository_dispatch to the external understand-quickly registry, so published graphs can be discovered/consumed via the registry’s stable API and MCP server.

Changes:

  • Introduces UnderstandQuicklyPublisher + options to stamp metadata.{tool, tool_version, generated_at, commit} and optionally dispatch a registry resync event.
  • Hooks the publisher into the Graphify artifact worker after a successful Graphify run (non-blocking, best-effort).
  • Adds xUnit tests for disabled/enabled/no-token/token-dispatch cases, and documents UNDERSTAND_QUICKLY_TOKEN in the README.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
README.md Documents new UNDERSTAND_QUICKLY_TOKEN env var and intended behavior.
src/OpenDeepWiki/Program.cs Adds DI/options binding for the opt-in UnderstandQuickly publisher.
src/OpenDeepWiki/Services/Graphify/GraphifyArtifactWorker.cs Invokes publisher after successful Graphify generation (best-effort).
src/OpenDeepWiki/Services/Graphify/UnderstandQuicklyPublisher.cs Implements metadata stamping + optional GitHub repository_dispatch call.
tests/OpenDeepWiki.Tests/Services/Graphify/UnderstandQuicklyPublisherTests.cs Adds test coverage for publisher no-op/stamp/dispatch behaviors.

Comment on lines +49 to +53
HttpClient? httpClient = null)
{
_options = options.Value;
_logger = logger;
_httpClient = httpClient ?? new HttpClient();
Comment on lines +114 to +125
var payload = new JsonObject
{
["event_type"] = DispatchEventType,
["client_payload"] = new JsonObject
{
["repo"] = repoSlug,
["schema"] = _options.Schema,
["graph_path"] = result.GraphJsonPath,
["tool"] = ToolName,
["tool_version"] = toolVersion,
["commit"] = result.CommitId,
},
await using var read = File.OpenRead(result.GraphJsonPath);
var node = await JsonNode.ParseAsync(read, cancellationToken: cancellationToken)
?? new JsonObject();
await read.DisposeAsync();
Comment on lines +96 to +100
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to stamp metadata into {Path}", result.GraphJsonPath);
return new UnderstandQuicklyPublishResult(false, false, ex.Message);
}
Comment on lines +29 to +80
Directory.CreateDirectory(tmp);
var graph = Path.Combine(tmp, "graph.json");
await File.WriteAllTextAsync(graph, "{\"nodes\":[],\"links\":[]}");

var options = Options.Create(new UnderstandQuicklyOptions { Enabled = false });
var publisher = new UnderstandQuicklyPublisher(
options, NullLogger<UnderstandQuicklyPublisher>.Instance);

var result = await publisher.PublishAsync(MakeResult(graph), "owner/repo");

Assert.False(result.MetadataStamped);
Assert.False(result.Dispatched);

// File untouched.
var doc = JsonNode.Parse(await File.ReadAllTextAsync(graph));
Assert.Null(doc!["metadata"]);

Directory.Delete(tmp, recursive: true);
}

[Fact]
public async Task PublishAsync_EnabledNoToken_StampsButDoesNotDispatch()
{
var tmp = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tmp);
var graph = Path.Combine(tmp, "graph.json");
await File.WriteAllTextAsync(graph, "{\"nodes\":[],\"links\":[]}");

var options = Options.Create(new UnderstandQuicklyOptions { Enabled = true });
var handler = new Mock<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("dispatch should not be called"));
var publisher = new UnderstandQuicklyPublisher(
options, NullLogger<UnderstandQuicklyPublisher>.Instance,
new HttpClient(handler.Object));

var result = await publisher.PublishAsync(MakeResult(graph, "abc123"), "owner/repo");

Assert.True(result.MetadataStamped);
Assert.False(result.Dispatched);
Assert.Null(result.Error);

var doc = JsonNode.Parse(await File.ReadAllTextAsync(graph))!.AsObject();
var md = doc["metadata"]!.AsObject();
Assert.Equal("opendeepwiki", md["tool"]!.GetValue<string>());
Assert.Equal("abc123", md["commit"]!.GetValue<string>());
Assert.NotNull(md["tool_version"]);
Assert.EndsWith("Z", md["generated_at"]!.GetValue<string>());

Directory.Delete(tmp, recursive: true);
Comment on lines +52 to +81
var tmp = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tmp);
var graph = Path.Combine(tmp, "graph.json");
await File.WriteAllTextAsync(graph, "{\"nodes\":[],\"links\":[]}");

var options = Options.Create(new UnderstandQuicklyOptions { Enabled = true });
var handler = new Mock<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("dispatch should not be called"));
var publisher = new UnderstandQuicklyPublisher(
options, NullLogger<UnderstandQuicklyPublisher>.Instance,
new HttpClient(handler.Object));

var result = await publisher.PublishAsync(MakeResult(graph, "abc123"), "owner/repo");

Assert.True(result.MetadataStamped);
Assert.False(result.Dispatched);
Assert.Null(result.Error);

var doc = JsonNode.Parse(await File.ReadAllTextAsync(graph))!.AsObject();
var md = doc["metadata"]!.AsObject();
Assert.Equal("opendeepwiki", md["tool"]!.GetValue<string>());
Assert.Equal("abc123", md["commit"]!.GetValue<string>());
Assert.NotNull(md["tool_version"]);
Assert.EndsWith("Z", md["generated_at"]!.GetValue<string>());

Directory.Delete(tmp, recursive: true);
}
Comment on lines +86 to +123
var tmp = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tmp);
var graph = Path.Combine(tmp, "graph.json");
await File.WriteAllTextAsync(graph, "{\"nodes\":[],\"links\":[]}");

HttpRequestMessage? captured = null;
var handler = new Mock<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));

var options = Options.Create(new UnderstandQuicklyOptions
{
Enabled = true,
Token = "ghp_fake",
});
var publisher = new UnderstandQuicklyPublisher(
options, NullLogger<UnderstandQuicklyPublisher>.Instance,
new HttpClient(handler.Object));

var result = await publisher.PublishAsync(MakeResult(graph), "looptech-ai/demo");

Assert.True(result.MetadataStamped);
Assert.True(result.Dispatched);
Assert.NotNull(captured);
Assert.Equal("https://api.github.com/repos/looptech-ai/understand-quickly/dispatches",
captured!.RequestUri!.ToString());
Assert.Equal("Bearer", captured.Headers.Authorization!.Scheme);
var body = await captured.Content!.ReadAsStringAsync();
var payload = JsonDocument.Parse(body).RootElement;
Assert.Equal("uq-publish", payload.GetProperty("event_type").GetString());
Assert.Equal("looptech-ai/demo",
payload.GetProperty("client_payload").GetProperty("repo").GetString());

Directory.Delete(tmp, recursive: true);
}
Comment thread src/OpenDeepWiki/Program.cs Outdated
options.Enabled = true;
}
});
builder.Services.AddScoped<IUnderstandQuicklyPublisher, UnderstandQuicklyPublisher>();
Comment on lines +141 to +156
if (response.IsSuccessStatusCode)
{
_logger.LogInformation(
"UnderstandQuickly: dispatched to {Registry} for {Slug} (HTTP {Code}).",
_options.RegistryRepo, repoSlug, (int)response.StatusCode);
return new UnderstandQuicklyPublishResult(true, true);
}
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogInformation(
"UnderstandQuickly: {Slug} not in registry — register once with `npx @understand-quickly/cli add`.",
repoSlug);
return new UnderstandQuicklyPublishResult(true, false, "repo not registered");
}
return new UnderstandQuicklyPublishResult(true, false, $"HTTP {(int)response.StatusCode}");
}
Address Copilot review on PR AIDotNet#361:

- Register `UnderstandQuicklyPublisher` via `AddHttpClient<,>` (typed
  client) so the framework manages `HttpMessageHandler` lifetimes.
  The constructor now takes a non-nullable `HttpClient`; drop the
  `httpClient ?? new HttpClient()` fallback that leaked undisposed
  handlers in a scoped service.
- Send `graph_path` as a repo-relative, forward-slash path derived
  from `OutputRoot` instead of the absolute server path. The
  understand-quickly registry fetches via `raw.githubusercontent.com`
  and an absolute path was both incorrect and a minor info leak in
  Action logs. Falls back to the file name when OutputRoot is missing.
- Let `OperationCanceledException` propagate out of both broad catch
  blocks so the background worker can shut down cleanly instead of
  converting cancellation into a misleading warning.
- Drop the redundant explicit `await read.DisposeAsync()` after the
  `await using` declaration; switch to a block-scoped `await using`
  so the read stream is disposed before the write begins.
- Log status code + (trimmed) response body on non-success / non-404
  responses to make 401/403/422 triage practical.
- Wrap test temp-dir cleanup in try/finally so a failed assertion
  doesn't leak directories on the CI agent. Add coverage asserting
  `graph_path` is repo-relative, plus a `ToRepoRelativePath`
  unit test using platform-real paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@amacsmith
Copy link
Copy Markdown
Author

Thanks for the Copilot review — addressed the substantive findings in 1751236:

  • HttpClient lifecycle (Program.cs:247, UnderstandQuicklyPublisher.cs:46-54): now registered via AddHttpClient<IUnderstandQuicklyPublisher, UnderstandQuicklyPublisher>() (typed client). Constructor takes a non-nullable HttpClient; removed the httpClient ?? new HttpClient() fallback that would have leaked undisposed handlers from a scoped service.
  • graph_path is now repo-relative (UnderstandQuicklyPublisher.cs:120-225): derived from OutputRoot and converted to forward slashes. The understand-quickly registry fetches graphs from raw.githubusercontent.com, so the previous absolute server path was both wrong and a minor info leak in Action logs. Falls back to the file name when OutputRoot is missing. Test added asserting graph_path is never Path.IsPathRooted.
  • Cancellation handling: both broad catch (Exception) blocks now rethrow OperationCanceledException when the token is cancelled, so the background worker shuts down cleanly instead of swallowing cancellation into a warning.
  • Stream disposal: removed the redundant explicit await read.DisposeAsync(); switched to a block-scoped await using so the read stream is disposed before the write begins.
  • Non-success logging: log status + (trimmed) response body on responses other than 2xx/404, so 401/403/422 cases are debuggable.
  • Tests: temp-dir cleanup wrapped in try/finally; added a ToRepoRelativePath unit test using platform-real paths.

I couldn't run dotnet test locally (no .NET 10 SDK on this machine) — would appreciate a CI run on this branch to confirm. Skipped the style nit about logging/log shape consistency. Happy to iterate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants