Skip to content

v0.23.0 feat(snapshot): team-shared graph artifact via GitHub Releases#18

Merged
sdsrss merged 22 commits into
mainfrom
feat/shared-graph-snapshot
May 10, 2026
Merged

v0.23.0 feat(snapshot): team-shared graph artifact via GitHub Releases#18
sdsrss merged 22 commits into
mainfrom
feat/shared-graph-snapshot

Conversation

@sdsrss
Copy link
Copy Markdown
Owner

@sdsrss sdsrss commented May 10, 2026

Summary

Adds a producer/consumer pair for code-graph-snapshot-<sha>.db.zst GitHub Release artifacts so fresh clones skip the initial full code-graph index. Snapshot is an optimization, not a dependency — every failure mode falls back to the existing run_full_index path.

Producer: code-graph-mcp snapshot create|inspect CLI + claude-plugin/templates/code-graph-snapshot.yml workflow template (users copy to their .github/workflows/).

Consumer: Server::from_project_root runs snapshot install BEFORE self.db opens (Approach A — avoids POSIX rename inode-swap). ensure_indexed routes to incremental when snapshot data is present.

File: symbols + edges + FTS5 only (no node_vectors) → decouples from embedding model choice. ~3-5MB compressed.

Spec: docs/superpowers/specs/2026-05-10-shared-graph-snapshot-design.md (local-only).

What's in this PR

  • Producer (Tasks 1-6): src/snapshot/{mod,meta,install,config}.rs + snapshot create|inspect CLI + workflow template + README docs
  • Consumer (Tasks 7-11): .code-graph.toml config + resolve_snapshot_source (toml override + gh api auto-detect with 5s SIGTERM watchdog) + try_install (download → zstd cap 100MB → schema validate → atomic rename → meta) + ensure_indexed hook + reindex --from-snapshot CLI
  • Observability (Task 12): health-check --json exposes snapshot status / source / drift
  • Tests (Tasks 13-16): 16 unit + 6 integration + 2 CLI smoke + 3 ignored bench
  • Release (Task 17): version 0.23.0 synced across 9 files + CHANGELOG entry

Bench (--ignored, on 5K-node fixture)

Metric Target Actual
create() runtime <5s 793ms
Compressed size <2MB 313KB
try_install() (file://) <1s 25ms
Node count parity vs full-index 0 delta 0 (1300=1300)

Final review fixes (commit 552729d)

Whole-implementation review surfaced 3 Important issues, all addressed:

  1. Concurrent cleanup no longer deletes a successfully-installed final_db under a post-rename meta-write race
  2. gh api watchdog is cancellation-aware (doesn't sleep 5s and SIGTERM a recycled PID after fast response)
  3. libc::kill properly #[cfg(unix)]-gated for Windows binary

Known follow-ups (not blocking)

  • inspect() reads whole compressed file into RAM (CLI-only, bounded by 100MB cap)
  • Server::try_install_snapshot is currently dead — Task 11 cmd_reindex calls crate::snapshot::* directly; can prune in a follow-up
  • cmd_reindex --from-snapshot lacks dedicated e2e test (integration tests cover the install path)
  • Compressed download has no streaming cap (TODO comment in install.rs::download)

Test plan

  • cargo test --no-default-features — 320 lib + 62 + 46 + 55 + 19 + 6 + 6 + 6 + 3 + 3 + 2 = all pass
  • cargo +1.95.0 clippy --no-default-features -- -D warnings — clean
  • cargo +1.95.0 clippy --all-targets -- -D warnings — clean
  • cargo test --test snapshot_bench -- --ignored — 3 pass, all targets met
  • CLI smoke: snapshot createsnapshot inspect round-trip on real repo
  • Final whole-implementation review (3 Important fixes applied in 552729d)

22 commits, +1670/-44, 25 files. Each commit individually reviewed (spec + code quality stages per task).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added snapshot create and snapshot inspect CLI commands for managing compressed graph snapshots.
    • Automatic snapshot fetching from GitHub releases on first server startup when no local index exists.
    • Added reindex --from-snapshot command to bootstrap indexing from a published snapshot.
    • Health check now displays snapshot status in JSON output.
    • Added GitHub Actions workflow template for publishing snapshots to releases.
  • Documentation

    • Updated README with team-shared graph snapshot workflow and setup instructions.
    • Added changelog entry documenting snapshot feature and new subcommands.
  • Chores

    • Version bumped to 0.23.0 across all packages.

Review Change Stack

sdsrss and others added 22 commits May 10, 2026 22:13
…ACUUM INTO

TDD: red test first (create_writes_meta_and_drops_vec_table), then implementation.
Promotes tempfile from dev-dependency to regular dep so snapshot::create() can
use it in production code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Priority order: HTTPS url from .code-graph.toml > disabled=true guard >
git-remote auto-detect via `gh api` GitHub releases. 4 TDD tests cover
toml-url, disabled, http-rejection, and no-git fallback paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ame + 100MB cap

Move reqwest from [dev-dependencies] to [dependencies] so try_install can use
blocking HTTP in lib code; file:// path enables hermetic unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…install (Approach A)

Investigation finding: self.db is a plain Database (not Arc<Database>), opened in
from_project_root via open_db_for_role before any indexing runs. Installing a snapshot
via POSIX rename(2) AFTER self.db opens would leave the connection pointing at the old
empty inode — queries would silently see no data.

Fix (Approach A — preferred): call maybe_install_snapshot() inside from_project_root
BEFORE open_db_for_role, guarded by !db_path.exists(). This way the connection we open
lands directly on the post-install file, avoiding the inode-swap entirely. The background
indexing path (spawn_startup_indexing) already opens its own fresh Database connection from
the path, so it was inode-safe regardless; only the construction-time and sync ensure_indexed
paths needed the fix.

Also updates ensure_indexed's synchronous fallback (when startup indexing hasn't run yet):
it now checks has_existing via get_index_status before deciding between run_full_index and
run_incremental_index. When snapshot data is present, it runs incremental drift-correction
instead of a full reindex — matching the behavior of spawn_startup_indexing which already
had the has_existing check at line 465.

Adds try_install_snapshot() method on McpServer as a post-construction helper (marked
dead_code for Task 11/12), and a test that proves the inode-safety contract: snapshot
installed before from_project_root → self.db sees nodes without calling ensure_indexed.

All 518 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a `snapshot` block to `cmd_health_check` JSON output with
status/source_url/source_commit/fetched_at/commit_drift fields.
Also emits a `Snapshot: <status>` line in the oneline format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… concurrent

Also fix try_install to use per-invocation unique partial file names (via
AtomicU64 counter) so concurrent callers don't clobber each other's
in-progress partials; the final atomic rename serialises the winner.
Error-path cleanup now guards final_db removal behind db_partial_gone
to avoid evicting a concurrent winner's installed db.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 10, 2026

📝 Walkthrough

Walkthrough

This PR introduces a team-shared graph snapshot feature that allows precomputed code-graph indexes to be published and automatically fetched by consumers. It adds snapshot creation/inspection CLI commands, MCP server auto-fetch on first start, TOML-based configuration, a GitHub Actions workflow template, and comprehensive tests covering creation, installation, validation, and integration with incremental indexing.

Changes

Snapshot System Implementation

Layer / File(s) Summary
Snapshot Data Types and Metadata
src/snapshot/meta.rs
SnapshotMeta struct with metadata constants (META_SNAPSHOT_*); write_meta and read_meta for SQLite persistence to store provenance.
Configuration and Source Resolution
src/snapshot/config.rs, src/snapshot/install.rs
CodeGraphConfig and SnapshotConfig for .code-graph.toml parsing; resolve_snapshot_source checks config URL or queries latest GitHub release asset via gh api.
Snapshot Core Operations
src/snapshot/install.rs, src/snapshot/mod.rs
snapshot::create builds compressed DB with optional node_vectors drop; snapshot::inspect decompresses and extracts metadata; try_install downloads, validates schema/commit, and atomically installs with 100MB decompression cap.
Snapshot CLI Commands
src/cli.rs
cmd_snapshot_create (--out, --include-embeddings, --root, --quiet), cmd_snapshot_inspect, and cmd_reindex (--from-snapshot) with fallback to incremental index.
Health Check and Status Reporting
src/cli.rs
Extended cmd_health_check to read snapshot metadata, compute commit_drift, and include snapshot status in JSON and human-readable output.
Main CLI Routing and Exports
src/lib.rs, src/main.rs
Export pub mod snapshot; add reindex and snapshot create/inspect subcommands with help text and typo suggestions.
MCP Server Snapshot Integration
src/mcp/server/mod.rs
Pre-open snapshot installation for primary instances before DB connection; try_install_snapshot for post-construction use; optimize ensure_indexed to run incremental drift-check when snapshot data already present.
Version and Dependency Updates
Cargo.toml, package manifests
Bump version 0.22.2 to 0.23.0; add reqwest (blocking, json, rustls-tls), toml, zstd, tempfile to dependencies.
Documentation and Release Workflow
README.md, CHANGELOG.md, claude-plugin/templates/code-graph-snapshot.yml
Document snapshot setup and team sharing; add GitHub Actions template to build and upload .db.zst on release; update all version fields to 0.23.0.
Unit Tests
src/snapshot/tests.rs
Meta read/write, snapshot creation, inspection, config loading, source resolution, and installation round-trip with error handling.
CLI Integration Tests
tests/cli_snapshot.rs
Smoke tests for snapshot create and snapshot inspect commands with JSON output validation.
Performance and Integration Tests
tests/snapshot_bench.rs, tests/snapshot_integration.rs
Benchmarks for creation (under 5s), compression (under 2MB), install speed (under 1s); integration tests for corruption handling, schema validation, size limits, concurrency, and post-install incremental indexing.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 A snapshot arrives, swift and lean,
Shared across teams, our graph so clean,
No need to index from the start so slow,
Just fetch and install—let the code graph flow!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a team-shared graph snapshot feature that can be distributed via GitHub Releases, allowing fresh clones to skip full indexing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shared-graph-snapshot

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (2)
src/snapshot/meta.rs (1)

38-49: 💤 Low value

Use rusqlite::OptionalExtension::optional() for cleaner None mapping.

query_row(...).optional()? is the idiomatic pattern for optional query results and replaces the manual QueryReturnedNoRows discrimination with a single method call.

♻️ Proposed refactor
 pub fn read_meta(conn: &Connection, key: &str) -> Result<Option<String>> {
+    use rusqlite::OptionalExtension;
     conn.query_row(
         "SELECT value FROM meta WHERE key = ?1",
         rusqlite::params![key],
         |row| row.get::<_, String>(0),
     )
-    .map(Some)
-    .or_else(|e| match e {
-        rusqlite::Error::QueryReturnedNoRows => Ok(None),
-        other => Err(anyhow::anyhow!(other)),
-    })
+    .optional()
+    .with_context(|| format!("read_meta({key})"))
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/snapshot/meta.rs` around lines 38 - 49, The read_meta function currently
handles QueryReturnedNoRows manually; replace that logic by using
rusqlite::OptionalExtension::optional(): import/use OptionalExtension, call
conn.query_row("SELECT value FROM meta WHERE key = ?1", rusqlite::params![key],
|row| row.get::<_, String>(0)).optional()? and return the Option<String>
directly from read_meta (keeping the Result return type), removing the manual
or_else match on QueryReturnedNoRows; ensure read_meta still returns
Result<Option<String>> and that OptionalExtension is in scope.
src/mcp/server/mod.rs (1)

2488-2491: ⚡ Quick win

Use the standard src/lib.rs layout in this regression fixture.

This test is about pre-open snapshot visibility, but both temp repos put the Rust file at the repo root. That adds an unrelated dependency on the indexer continuing to scan nonstandard Rust roots, so a future file-discovery change could break this inode-swap regression for the wrong reason.

Suggested change
-        std::fs::write(
-            source.path().join("lib.rs"),
+        std::fs::create_dir_all(source.path().join("src")).unwrap();
+        std::fs::write(
+            source.path().join("src/lib.rs"),
             "pub fn snapshot_sentinel() {}\npub fn snapshot_caller() { snapshot_sentinel(); }\n",
         ).unwrap();
@@
-        std::fs::write(
-            consumer.path().join("lib.rs"),
+        std::fs::create_dir_all(consumer.path().join("src")).unwrap();
+        std::fs::write(
+            consumer.path().join("src/lib.rs"),
             "pub fn snapshot_sentinel() {}\npub fn snapshot_caller() { snapshot_sentinel(); }\n",
         ).unwrap();

Also applies to: 2506-2509

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mcp/server/mod.rs` around lines 2488 - 2491, The regression fixture
currently writes the Rust file at the repository root via
source.path().join("lib.rs"); change the fixture to create a standard src
directory and write the file to source.path().join("src").join("lib.rs")
(ensuring the "src" directory exists) so the test uses the conventional
src/lib.rs layout; update the analogous occurrences around the other write calls
that place lib.rs at repo root (the same write(...) call creating "lib.rs" and
the test snippets that define snapshot_sentinel and snapshot_caller) to use the
src/lib.rs location instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@claude-plugin/templates/code-graph-snapshot.yml`:
- Around line 33-35: The GH release upload step uses the `gh release upload`
command to push the artifact "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst" but
is non-idempotent and will fail on re-run; update the run block that invokes `gh
release upload` (the command shown as `gh release upload "${{
github.event.release.tag_name }}"
"code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"`) to include the `--clobber` flag
so repeated workflow runs replace the existing asset instead of erroring with
already_exists.
- Around line 27-29: The workflow is calling the wrong npm package name in the
shell step: replace the npx invocation string "npx -y code-graph-mcp@latest
snapshot create --out snapshot.db" with the correct package "@sdsrs/code-graph"
(e.g., use "npx -y `@sdsrs/code-graph`@latest snapshot create --out snapshot.db")
so the snapshot create command uses the published package; update any identical
occurrences of "code-graph-mcp" in the template to "@sdsrs/code-graph".

In `@README.md`:
- Around line 323-324: Update the README instruction that copies the workflow
template: replace the incorrect package path
"node_modules/code-graph-mcp/templates/code-graph-snapshot.yml" with the actual
published package path
"node_modules/@sdsrs/code-graph/templates/code-graph-snapshot.yml" so the cp
command matches the npm package referenced elsewhere (npx -y `@sdsrs/code-graph`)
and users won't get "cannot stat" errors.

In `@src/snapshot/install.rs`:
- Around line 132-134: The bail message in the conditional that checks the
snapshot URL (the if !(url.starts_with("https://") ||
url.starts_with("file://")) block) is misleading because it only mentions
"https://" even though "file://" is also accepted; update the anyhow::bail! call
to list both accepted schemes (e.g., "snapshot url must start with https:// or
file:// (got {url})") so the error accurately reflects the allowed URL schemes.
- Around line 8-25: The doc comment for resolve_snapshot_source lists resolution
order incorrectly vs the code: the function currently checks
cfg.snapshot.disabled before cfg.snapshot.url and thus short-circuits; either
update the doc to list "disabled" before "url" to match current behavior, or if
the intended behavior is that an explicit cfg.snapshot.url should override
disablement, move the cfg.snapshot.url check (the if let Some(url) =
cfg.snapshot.url) above the cfg.snapshot.disabled check so the URL is honored
first; adjust the doc or the order in resolve_snapshot_source accordingly.
- Around line 91-126: In wait_with_watchdog, the watchdog thread may call
libc::kill(pid, SIGTERM) after its loop even if the main thread just set cancel;
before sending the signal you must re-check the cancel flag to avoid killing a
recycled PID. Modify the closure spawned into watchdog (referencing pid and
cancel_w) to, after the loop exits due to timeout, test
cancel_w.load(Ordering::Relaxed) and return if true, only calling
libc::kill(pid, libc::SIGTERM) when cancel_w is still false; keep the existing
join/store behavior in the main thread (cancel.store(true) and join).
- Around line 194-211: The download() function currently buffers the entire HTTP
response via Response::bytes(), which can OOM; change it to stream the response
to disk and enforce a compressed-size cap by using Response::copy_to with a
capped writer instead of bytes(). Specifically, in download() (and the HTTP
branch where reqwest::blocking::Client is built and .get(url).send() is called)
create the destination file (std::fs::File::create(dest)), wrap it in the
existing CapWriter with a MAX_COMPRESSED_BYTES constant, and call
resp.copy_to(&mut capped_writer) (checking error_for_status() first) so the
compressed payload is limited and written incrementally to disk rather than
fully buffered in memory.

In `@src/snapshot/mod.rs`:
- Around line 96-103: The current inspect path leaks memory by reading the whole
compressed file and using zstd::decode_all then calling Database::open (which
may apply migrations); replace that with the install path’s capped, streaming
decompression routine (do not use zstd::decode_all or read the full file into a
Vec) to write at most the configured max bytes into the temp snapshot.db, and
open the resulting temp DB read-only (use the install helper or rusqlite
open-with-flags; replace Database::open(&decompressed)? with a read-only open
such as Database::open_read_only or opening via
rusqlite::Connection::open_with_flags(..., OpenFlags::SQLITE_OPEN_READ_ONLY)).
Ensure you reference and remove the use of the variables/steps
compressed/zstd::decode_all/raw and instead call the capped streaming
decompressor used by the install code and then open the temp DB in read-only
mode for inspection.

In `@tests/cli_snapshot.rs`:
- Around line 13-19: The git setup commands in the test (the Command::new calls
for "git init", "git config", "git add", "git commit") only call
status().unwrap(), which only ensures the process started but not that it exited
successfully; change these to assert the exit status succeeded by checking
status.success() (or replace repeated calls with a small helper like
run_and_assert(cmd) that runs the command, calls .status().expect("spawn
failed") and then assert!(status.success(), "git <action> failed: {:?}",
status)), referencing the Command invocations shown in the test (e.g., the
Command::new("git").args([...]).current_dir(p).status() calls) so any setup
failure fails the test immediately with a clear message.

---

Nitpick comments:
In `@src/mcp/server/mod.rs`:
- Around line 2488-2491: The regression fixture currently writes the Rust file
at the repository root via source.path().join("lib.rs"); change the fixture to
create a standard src directory and write the file to
source.path().join("src").join("lib.rs") (ensuring the "src" directory exists)
so the test uses the conventional src/lib.rs layout; update the analogous
occurrences around the other write calls that place lib.rs at repo root (the
same write(...) call creating "lib.rs" and the test snippets that define
snapshot_sentinel and snapshot_caller) to use the src/lib.rs location instead.

In `@src/snapshot/meta.rs`:
- Around line 38-49: The read_meta function currently handles
QueryReturnedNoRows manually; replace that logic by using
rusqlite::OptionalExtension::optional(): import/use OptionalExtension, call
conn.query_row("SELECT value FROM meta WHERE key = ?1", rusqlite::params![key],
|row| row.get::<_, String>(0)).optional()? and return the Option<String>
directly from read_meta (keeping the Result return type), removing the manual
or_else match on QueryReturnedNoRows; ensure read_meta still returns
Result<Option<String>> and that OptionalExtension is in scope.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bb419406-4ae0-416e-a53f-693ec092f60e

📥 Commits

Reviewing files that changed from the base of the PR and between c3e3cba and 552729d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (24)
  • .claude-plugin/marketplace.json
  • CHANGELOG.md
  • Cargo.toml
  • README.md
  • claude-plugin/.claude-plugin/plugin.json
  • claude-plugin/templates/code-graph-snapshot.yml
  • npm/darwin-arm64/package.json
  • npm/darwin-x64/package.json
  • npm/linux-arm64/package.json
  • npm/linux-x64/package.json
  • npm/win32-x64/package.json
  • package.json
  • src/cli.rs
  • src/lib.rs
  • src/main.rs
  • src/mcp/server/mod.rs
  • src/snapshot/config.rs
  • src/snapshot/install.rs
  • src/snapshot/meta.rs
  • src/snapshot/mod.rs
  • src/snapshot/tests.rs
  • tests/cli_snapshot.rs
  • tests/snapshot_bench.rs
  • tests/snapshot_integration.rs

Comment on lines +27 to +29
npx -y code-graph-mcp@latest snapshot create --out snapshot.db
zstd -9 snapshot.db -o snapshot.db.zst
mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

npmjs.com @sdsrs/code-graph package name registry

💡 Result:

The npm registry package name for “@sdsrs/code-graph” is exactly: @sdsrs/code-graph [1][2] Its npm registry page is: https://registry.npmjs.org/%40sdsrs%2Fcode-graph [1] And the package page on npmjs.com is: https://www.npmjs.com/package/@sdsrs/code-graph [2] This package describes itself as “MCP server that indexes codebases into an AST knowledge graph …” and provides install command npm i @sdsrs/code-graph``. [2]

Citations:


Fix incorrect npm package name — workflow will fail on every run.

The workflow references code-graph-mcp@latest, but the published npm package on the registry is @sdsrs/code-graph. Running npx -y code-graph-mcp@latest will produce a 404 error (npm error 404 Not Found - GET https://registry.npmjs.org/code-graph-mcp).

Use @sdsrs/code-graph instead:

Proposed fix
       - name: Build snapshot
         run: |
-          npx -y code-graph-mcp@latest snapshot create --out snapshot.db
+          npx -y `@sdsrs/code-graph`@latest snapshot create --out snapshot.db
           zstd -9 snapshot.db -o snapshot.db.zst
           mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
npx -y code-graph-mcp@latest snapshot create --out snapshot.db
zstd -9 snapshot.db -o snapshot.db.zst
mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
npx -y `@sdsrs/code-graph`@latest snapshot create --out snapshot.db
zstd -9 snapshot.db -o snapshot.db.zst
mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@claude-plugin/templates/code-graph-snapshot.yml` around lines 27 - 29, The
workflow is calling the wrong npm package name in the shell step: replace the
npx invocation string "npx -y code-graph-mcp@latest snapshot create --out
snapshot.db" with the correct package "@sdsrs/code-graph" (e.g., use "npx -y
`@sdsrs/code-graph`@latest snapshot create --out snapshot.db") so the snapshot
create command uses the published package; update any identical occurrences of
"code-graph-mcp" in the template to "@sdsrs/code-graph".

Comment on lines +33 to +35
run: |
gh release upload "${{ github.event.release.tag_name }}" \
"code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

gh release upload is non-idempotent — add --clobber for re-runs.

Without --clobber, retrying this workflow on an already-published release (manual re-run, transient failure recovery, hot-fix) fails with HTTP 422: Validation Failed (already_exists). Since the asset name is content-addressed by short SHA, a fresh upload of the same artifact for the same release is the intended behavior on retry.

📝 Proposed fix
           gh release upload "${{ github.event.release.tag_name }}" \
-            "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
+            "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst" \
+            --clobber
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@claude-plugin/templates/code-graph-snapshot.yml` around lines 33 - 35, The GH
release upload step uses the `gh release upload` command to push the artifact
"code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst" but is non-idempotent and will
fail on re-run; update the run block that invokes `gh release upload` (the
command shown as `gh release upload "${{ github.event.release.tag_name }}"
"code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"`) to include the `--clobber` flag
so repeated workflow runs replace the existing asset instead of erroring with
already_exists.

Comment thread README.md
Comment on lines +323 to +324
1. Copy `node_modules/code-graph-mcp/templates/code-graph-snapshot.yml`
into your repo's `.github/workflows/`.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Path uses the binary name instead of the npm package name.

The published npm package is @sdsrs/code-graph (the rest of the README references npx -y @sdsrs/code-graph``), so the template lands at node_modules/@sdsrs/code-graph/templates/code-graph-snapshot.yml. Users following these instructions verbatim will hit `cp: cannot stat 'node_modules/code-graph-mcp/...': No such file or directory`.

📝 Proposed fix
 **Setup (one-time):**
-1. Copy `node_modules/code-graph-mcp/templates/code-graph-snapshot.yml`
+1. Copy `node_modules/@sdsrs/code-graph/templates/code-graph-snapshot.yml`
    into your repo's `.github/workflows/`.

Note: the LanguageTool hint flagging "github" → "GitHub" on this line is a false positive — .github/workflows/ is the literal directory name and must stay lowercase.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
1. Copy `node_modules/code-graph-mcp/templates/code-graph-snapshot.yml`
into your repo's `.github/workflows/`.
1. Copy `node_modules/@sdsrs/code-graph/templates/code-graph-snapshot.yml`
into your repo's `.github/workflows/`.
🧰 Tools
🪛 LanguageTool

[uncategorized] ~324-~324: The official name of this software platform is spelled with a capital “H”.
Context: ...graph-snapshot.yml into your repo's.github/workflows/`. 2. Push a release tag. The...

(GITHUB)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 323 - 324, Update the README instruction that copies
the workflow template: replace the incorrect package path
"node_modules/code-graph-mcp/templates/code-graph-snapshot.yml" with the actual
published package path
"node_modules/@sdsrs/code-graph/templates/code-graph-snapshot.yml" so the cp
command matches the npm package referenced elsewhere (npx -y `@sdsrs/code-graph`)
and users won't get "cannot stat" errors.

Comment thread src/snapshot/install.rs
Comment on lines +8 to +25
/// Resolve where the snapshot lives. Order:
/// 1. `.code-graph.toml` `[snapshot] url` (must be HTTPS)
/// 2. `[snapshot] disabled = true` → None
/// 3. Auto-detect from `git remote get-url origin` → GitHub release asset
pub fn resolve_snapshot_source(root: &Path) -> Option<String> {
let cfg = load_config(root).ok()?;
if cfg.snapshot.disabled {
return None;
}
if let Some(url) = cfg.snapshot.url {
if url.starts_with("https://") {
return Some(url);
}
tracing::warn!("snapshot url in .code-graph.toml is not https, skipping: {url}");
return None;
}
resolve_from_github(root)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Doc comment lists resolution order in the wrong sequence.

The header lists url first, then disabled, but the code checks disabled first (line 14) and would short-circuit before reading the override URL. Either reorder the doc to match the code, or move the URL check above the disabled short-circuit if the documented behavior is intended.

📝 Proposed doc-only fix
 /// Resolve where the snapshot lives. Order:
-/// 1. `.code-graph.toml` `[snapshot] url` (must be HTTPS)
-/// 2. `[snapshot] disabled = true` → None
-/// 3. Auto-detect from `git remote get-url origin` → GitHub release asset
+/// 1. `[snapshot] disabled = true` → None
+/// 2. `.code-graph.toml` `[snapshot] url` (must be HTTPS)
+/// 3. Auto-detect from `git remote get-url origin` → GitHub release asset
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Resolve where the snapshot lives. Order:
/// 1. `.code-graph.toml` `[snapshot] url` (must be HTTPS)
/// 2. `[snapshot] disabled = true` → None
/// 3. Auto-detect from `git remote get-url origin` → GitHub release asset
pub fn resolve_snapshot_source(root: &Path) -> Option<String> {
let cfg = load_config(root).ok()?;
if cfg.snapshot.disabled {
return None;
}
if let Some(url) = cfg.snapshot.url {
if url.starts_with("https://") {
return Some(url);
}
tracing::warn!("snapshot url in .code-graph.toml is not https, skipping: {url}");
return None;
}
resolve_from_github(root)
}
/// Resolve where the snapshot lives. Order:
/// 1. `[snapshot] disabled = true` → None
/// 2. `.code-graph.toml` `[snapshot] url` (must be HTTPS)
/// 3. Auto-detect from `git remote get-url origin` → GitHub release asset
pub fn resolve_snapshot_source(root: &Path) -> Option<String> {
let cfg = load_config(root).ok()?;
if cfg.snapshot.disabled {
return None;
}
if let Some(url) = cfg.snapshot.url {
if url.starts_with("https://") {
return Some(url);
}
tracing::warn!("snapshot url in .code-graph.toml is not https, skipping: {url}");
return None;
}
resolve_from_github(root)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/snapshot/install.rs` around lines 8 - 25, The doc comment for
resolve_snapshot_source lists resolution order incorrectly vs the code: the
function currently checks cfg.snapshot.disabled before cfg.snapshot.url and thus
short-circuits; either update the doc to list "disabled" before "url" to match
current behavior, or if the intended behavior is that an explicit
cfg.snapshot.url should override disablement, move the cfg.snapshot.url check
(the if let Some(url) = cfg.snapshot.url) above the cfg.snapshot.disabled check
so the URL is honored first; adjust the doc or the order in
resolve_snapshot_source accordingly.

Comment thread src/snapshot/install.rs
Comment on lines +91 to +126
fn wait_with_watchdog(
child: std::process::Child,
cap: std::time::Duration,
) -> Option<std::process::Output> {
#[cfg(unix)]
{
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

let pid = child.id() as i32;
let cancel = Arc::new(AtomicBool::new(false));
let cancel_w = Arc::clone(&cancel);
let watchdog = std::thread::spawn(move || {
let start = std::time::Instant::now();
while start.elapsed() < cap {
if cancel_w.load(Ordering::Relaxed) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
// Cap exceeded: send SIGTERM. Pid is still alive because cancel
// is set immediately after wait_with_output returns.
unsafe { libc::kill(pid, libc::SIGTERM); }
});
let result = child.wait_with_output().ok().filter(|o| o.status.success());
cancel.store(true, Ordering::Relaxed);
let _ = watchdog.join();
result
}
#[cfg(not(unix))]
{
// No watchdog on non-Unix: rely on `gh.exe`'s own timeout behavior.
let _ = cap;
child.wait_with_output().ok().filter(|o| o.status.success())
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

SIGTERM path doesn't re-check cancel — could signal a recycled PID.

The watchdog's while start.elapsed() < cap loop exits unconditionally to libc::kill(pid, SIGTERM) once the cap elapses. If wait_with_output happens to complete near the cap boundary, the cancel.store(true) from the main thread can race with the watchdog's loop-exit decision: the watchdog has already left the loop, the child is reaped, the OS may reuse the PID, and the kill targets an unrelated process. The inline comment claims this is safe because cancel is set "immediately after wait_with_output returns," but the cancel flag is only consulted inside the loop, not after.

Add a final cancel check before libc::kill:

🔒 Proposed guard
         while start.elapsed() < cap {
             if cancel_w.load(Ordering::Relaxed) {
                 return;
             }
             std::thread::sleep(std::time::Duration::from_millis(100));
         }
-        // Cap exceeded: send SIGTERM. Pid is still alive because cancel
-        // is set immediately after wait_with_output returns.
-        unsafe { libc::kill(pid, libc::SIGTERM); }
+        // Cap exceeded — re-check cancel one more time so we don't SIGTERM
+        // a child that completed in the same instant the cap fired (PID
+        // could already be reaped and recycled).
+        if !cancel_w.load(Ordering::Relaxed) {
+            unsafe { libc::kill(pid, libc::SIGTERM); }
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/snapshot/install.rs` around lines 91 - 126, In wait_with_watchdog, the
watchdog thread may call libc::kill(pid, SIGTERM) after its loop even if the
main thread just set cancel; before sending the signal you must re-check the
cancel flag to avoid killing a recycled PID. Modify the closure spawned into
watchdog (referencing pid and cancel_w) to, after the loop exits due to timeout,
test cancel_w.load(Ordering::Relaxed) and return if true, only calling
libc::kill(pid, libc::SIGTERM) when cancel_w is still false; keep the existing
join/store behavior in the main thread (cancel.store(true) and join).

Comment thread src/snapshot/install.rs
Comment on lines +132 to +134
if !(url.starts_with("https://") || url.starts_with("file://")) {
anyhow::bail!("snapshot url must be https:// (got {url})");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Error message contradicts the accepted scheme list.

The check accepts https:// and file://, but the bail message only mentions https. A user feeding e.g. http:// or s3:// will get a message that omits the legitimate file:// test path.

📝 Proposed message tweak
-    if !(url.starts_with("https://") || url.starts_with("file://")) {
-        anyhow::bail!("snapshot url must be https:// (got {url})");
-    }
+    if !(url.starts_with("https://") || url.starts_with("file://")) {
+        anyhow::bail!("snapshot url must be https:// or file:// (got {url})");
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if !(url.starts_with("https://") || url.starts_with("file://")) {
anyhow::bail!("snapshot url must be https:// (got {url})");
}
if !(url.starts_with("https://") || url.starts_with("file://")) {
anyhow::bail!("snapshot url must be https:// or file:// (got {url})");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/snapshot/install.rs` around lines 132 - 134, The bail message in the
conditional that checks the snapshot URL (the if !(url.starts_with("https://")
|| url.starts_with("file://")) block) is misleading because it only mentions
"https://" even though "file://" is also accepted; update the anyhow::bail! call
to list both accepted schemes (e.g., "snapshot url must start with https:// or
file:// (got {url})") so the error accurately reflects the allowed URL schemes.

Comment thread src/snapshot/install.rs
Comment on lines +194 to +211
fn download(url: &str, dest: &Path) -> Result<()> {
if let Some(file_path) = url.strip_prefix("file://") {
// file:// is test-only and config-controlled; no path sanitisation.
std::fs::copy(file_path, dest).context("file:// copy")?;
return Ok(());
}
// TODO: stream to disk (reqwest copy_to) and apply MAX_DECOMPRESSED_BYTES
// cap to the compressed payload too — currently buffers the whole response.
let bytes = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?
.get(url)
.send()?
.error_for_status()?
.bytes()?;
std::fs::write(dest, &bytes).context("write download to disk")?;
Ok(())
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

reqwest blocking Response copy_to writer streaming download

💡 Result:

In reqwest's blocking API, use Response::copy_to(&mut self, writer: &mut W) where W: std::io::Write to stream download a response body to a writer like a File. This internally calls std::io::copy, reading and writing chunks continuously until EOF without loading the full body into memory [1][2]. Example for downloading to a file: use std::fs::File; use reqwest::blocking::get; fn main -> Result<, Box> { let mut response = get("http://httpbin.org/range/1024")?; let mut file = File::create("downloaded.bin")?; let bytes_copied = response.copy_to(&mut file)?; println!("Copied {} bytes", bytes_copied); Ok() }[1][2] This is ideal for large files, avoiding memory issues from response.bytes which buffers everything [3]. The official docs confirm it streams data [1]. For async, use chunk.await in a loop instead [4][5].

Citations:


🏁 Script executed:

# Search for MAX_DECOMPRESSED_BYTES constant
rg "MAX_DECOMPRESSED_BYTES" --type rs

Repository: sdsrss/code-graph-mcp

Length of output: 92


🏁 Script executed:

# Search for CapWriter implementation
rg "CapWriter|cap.*writer" --type rs -i

Repository: sdsrss/code-graph-mcp

Length of output: 92


🏁 Script executed:

# Look at the actual download function in snapshot/install.rs
fd "install.rs" src/ | head -1 | xargs wc -l

Repository: sdsrss/code-graph-mcp

Length of output: 93


🏁 Script executed:

# Check for decompression logic that might use MAX_DECOMPRESSED_BYTES
rg "decompress|decompressed" --type rs -A 2 -B 2

Repository: sdsrss/code-graph-mcp

Length of output: 92


🏁 Script executed:

# Search for MAX_DECOMPRESSED_BYTES constant
rg "MAX_DECOMPRESSED_BYTES"

Repository: sdsrss/code-graph-mcp

Length of output: 362


🏁 Script executed:

# Search for CapWriter implementation
rg "CapWriter|cap.*writer" -i

Repository: sdsrss/code-graph-mcp

Length of output: 368


🏁 Script executed:

# Read the download function from install.rs
cat -n src/snapshot/install.rs | sed -n '194,211p'

Repository: sdsrss/code-graph-mcp

Length of output: 922


🏁 Script executed:

# Check for decompression logic
rg "decompress|decompressed" -A 2 -B 2

Repository: sdsrss/code-graph-mcp

Length of output: 2375


download() buffers the entire response in memory before any cap applies.

reqwest::blocking::Response::bytes() reads the full payload into RAM before the cap-aware decoder runs. A malicious or misconfigured release endpoint serving a multi-GB body will OOM the process well before MAX_DECOMPRESSED_BYTES is consulted on the decompressed side. The TODO comment on line 200 already acknowledges this, but for a feature that runs unattended on MCP server startup it's worth implementing now rather than deferring.

reqwest::blocking::Response::copy_to(&mut writer) streams to a writer in chunks — wrapping the destination file in the existing CapWriter gives a compressed-side cap with no extra deps.

🛡️ Sketch of streaming + compressed cap
const MAX_COMPRESSED_BYTES: u64 = 200 * 1024 * 1024; // ~2× zstd worst case
let mut resp = reqwest::blocking::Client::builder()
    .timeout(std::time::Duration::from_secs(30))
    .build()?
    .get(url)
    .send()?
    .error_for_status()?;
let mut out = std::fs::File::create(dest).context("create download file")?;
let mut capped = CapWriter::new(&mut out, MAX_COMPRESSED_BYTES);
resp.copy_to(&mut capped).context("stream download to disk")?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/snapshot/install.rs` around lines 194 - 211, The download() function
currently buffers the entire HTTP response via Response::bytes(), which can OOM;
change it to stream the response to disk and enforce a compressed-size cap by
using Response::copy_to with a capped writer instead of bytes(). Specifically,
in download() (and the HTTP branch where reqwest::blocking::Client is built and
.get(url).send() is called) create the destination file
(std::fs::File::create(dest)), wrap it in the existing CapWriter with a
MAX_COMPRESSED_BYTES constant, and call resp.copy_to(&mut capped_writer)
(checking error_for_status() first) so the compressed payload is limited and
written incrementally to disk rather than fully buffered in memory.

Comment thread src/snapshot/mod.rs
Comment on lines +96 to +103
// Decompress to a temp file so we can open with rusqlite
let tmp = tempfile::tempdir().context("inspect tempdir")?;
let decompressed = tmp.path().join("snapshot.db");
let compressed = std::fs::read(file).context("read snapshot file")?;
let raw = zstd::decode_all(&compressed[..]).context("zstd decode")?;
std::fs::write(&decompressed, &raw).context("write decompressed snapshot")?;

let db = Database::open(&decompressed)?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep snapshot inspect bounded and read-only.

This path reads the entire archive and the entire decompressed DB into memory, then opens it with Database::open(). A malformed or unexpectedly large asset can OOM the CLI, and Database::open() may apply migrations to the temp copy so the reported counts no longer strictly reflect the artifact bytes. Please reuse the install path’s capped decompression logic here and open the temp DB read-only for inspection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/snapshot/mod.rs` around lines 96 - 103, The current inspect path leaks
memory by reading the whole compressed file and using zstd::decode_all then
calling Database::open (which may apply migrations); replace that with the
install path’s capped, streaming decompression routine (do not use
zstd::decode_all or read the full file into a Vec) to write at most the
configured max bytes into the temp snapshot.db, and open the resulting temp DB
read-only (use the install helper or rusqlite open-with-flags; replace
Database::open(&decompressed)? with a read-only open such as
Database::open_read_only or opening via
rusqlite::Connection::open_with_flags(..., OpenFlags::SQLITE_OPEN_READ_ONLY)).
Ensure you reference and remove the use of the variables/steps
compressed/zstd::decode_all/raw and instead call the capped streaming
decompressor used by the install code and then open the temp DB in read-only
mode for inspection.

Comment thread tests/cli_snapshot.rs
Comment on lines +13 to +19
Command::new("git").args(["init", "-q"]).current_dir(p).status().unwrap();
Command::new("git").args(["config", "user.email", "t@t"]).current_dir(p).status().unwrap();
Command::new("git").args(["config", "user.name", "t"]).current_dir(p).status().unwrap();
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/lib.rs"), "pub fn h() {}\n").unwrap();
Command::new("git").args(["add", "."]).current_dir(p).status().unwrap();
Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(p).status().unwrap();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert the git setup commands actually succeeded.

status().unwrap() only proves the process spawned. If git init/git commit exits non-zero here, the repo fixture is half-built and the later snapshot assertions fail far away from the real cause. Please check status.success() (or wrap these calls in a tiny helper) so setup failures surface immediately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cli_snapshot.rs` around lines 13 - 19, The git setup commands in the
test (the Command::new calls for "git init", "git config", "git add", "git
commit") only call status().unwrap(), which only ensures the process started but
not that it exited successfully; change these to assert the exit status
succeeded by checking status.success() (or replace repeated calls with a small
helper like run_and_assert(cmd) that runs the command, calls
.status().expect("spawn failed") and then assert!(status.success(), "git
<action> failed: {:?}", status)), referencing the Command invocations shown in
the test (e.g., the Command::new("git").args([...]).current_dir(p).status()
calls) so any setup failure fails the test immediately with a clear message.

@sdsrss sdsrss merged commit a5c685d into main May 10, 2026
9 checks passed
@sdsrss sdsrss deleted the feat/shared-graph-snapshot branch May 10, 2026 15:55
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.

1 participant