From 574cd55effbb2b972da375fb528e7c01bbe216da Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 19 Feb 2026 09:01:05 -0500 Subject: [PATCH] feat(cli): add 'lore count references' command (bd-2ez) Adds 'references' entity type to the count command with breakdowns by reference_type (closes/mentioned/related), source_method (api/note_parse/description_parse), and unresolved count. Includes human and robot output formatters, 2 unit tests. --- .beads/issues.jsonl | 2 +- .beads/last-touched | 2 +- src/cli/autocorrect.rs | 1 + src/cli/commands/count.rs | 240 ++++++++++++++++++++++++++++++++++++++ src/cli/commands/mod.rs | 5 +- src/cli/mod.rs | 4 +- src/main.rs | 23 +++- 7 files changed, 265 insertions(+), 12 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8bf7113..7595106 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -132,7 +132,7 @@ {"id":"bd-2dlt","title":"Implement GraphQL client with partial-error handling","description":"## Background\nGitLab's GraphQL endpoint (/api/graphql) uses different auth than REST (Bearer token, not PRIVATE-TOKEN). We need a minimal GraphQL client that handles the GitLab-specific error codes and partial-data responses per GraphQL spec. The client returns a GraphqlQueryResult struct that propagates partial-error metadata end-to-end.\n\n## Approach\nCreate a new file src/gitlab/graphql.rs with GraphqlClient (uses reqwest). Add httpdate crate for Retry-After HTTP-date parsing. Wire into the module tree. Factory on GitLabClient keeps token encapsulated.\n\n## Files\n- src/gitlab/graphql.rs (NEW) — GraphqlClient struct, GraphqlQueryResult, ansi256_from_rgb\n- src/gitlab/mod.rs (add pub mod graphql;)\n- src/gitlab/client.rs (add graphql_client() factory method)\n- Cargo.toml (add httpdate dependency)\n\n## Implementation\n\nGraphqlClient struct:\n Fields: http (reqwest::Client with 30s timeout), base_url (String), token (String)\n Constructor: new(base_url, token) — trims trailing slash from base_url\n \nquery() method:\n - POST to {base_url}/api/graphql\n - Headers: Authorization: Bearer {token}, Content-Type: application/json\n - Body: {\"query\": \"...\", \"variables\": {...}}\n - Returns Result\n\nGraphqlQueryResult struct (pub):\n data: serde_json::Value\n had_partial_errors: bool\n first_partial_error: Option\n\nHTTP status mapping:\n 401 | 403 -> LoreError::GitLabAuthFailed\n 404 -> LoreError::GitLabNotFound { resource: \"GraphQL endpoint\" }\n 429 -> LoreError::GitLabRateLimited { retry_after } (parse Retry-After: try u64 first, then httpdate::parse_http_date, fallback 60)\n Other non-success -> LoreError::Other\n\nGraphQL-level error handling:\n errors array present + data absent/null -> Err(LoreError::Other(\"GraphQL error: {first_msg}\"))\n errors array present + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some(first_msg) })\n No errors + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None })\n No errors + no data -> Err(LoreError::Other(\"missing 'data' field\"))\n\nansi256_from_rgb(r, g, b) -> u8:\n Maps RGB to nearest ANSI 256-color index using 6x6x6 cube (indices 16-231).\n MUST be placed BEFORE #[cfg(test)] module (clippy::items_after_test_module).\n\nFactory in src/gitlab/client.rs:\n pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient {\n crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token)\n }\n\n## Acceptance Criteria\n- [ ] query() sends POST with Bearer auth header\n- [ ] Success: returns GraphqlQueryResult { data, had_partial_errors: false }\n- [ ] Errors-only (no data): returns Err with first error message\n- [ ] Partial data + errors: returns Ok with had_partial_errors: true\n- [ ] 401 -> GitLabAuthFailed\n- [ ] 403 -> GitLabAuthFailed\n- [ ] 404 -> GitLabNotFound\n- [ ] 429 -> GitLabRateLimited (parses Retry-After delta-seconds and HTTP-date, fallback 60)\n- [ ] ansi256_from_rgb: (0,0,0)->16, (255,255,255)->231\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_graphql_query_success, test_graphql_query_with_errors_no_data, test_graphql_auth_uses_bearer, test_graphql_401_maps_to_auth_failed, test_graphql_403_maps_to_auth_failed, test_graphql_404_maps_to_not_found, test_graphql_partial_data_with_errors_returns_data, test_retry_after_http_date_format, test_retry_after_invalid_falls_back_to_60, test_ansi256_from_rgb\n Tests use wiremock or similar mock HTTP server\nGREEN: Implement GraphqlClient, add httpdate to Cargo.toml\nVERIFY: cargo test graphql && cargo test ansi256\n\n## Edge Cases\n- Use r##\"...\"## in tests containing \"#1f75cb\" hex colors (# breaks r#\"...\"#)\n- LoreError::GitLabRateLimited uses u64 not Option — use .unwrap_or(60)\n- httpdate::parse_http_date returns SystemTime — compute duration_since(now) for delta\n- GraphqlQueryResult is NOT Clone — tests must check fields individually","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:41:52.833151Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.417835Z","closed_at":"2026-02-11T07:21:33.417793Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dlt","depends_on_id":"bd-1v8t","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2dlt","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2e8","title":"Add fetchResourceEvents config flag to SyncConfig","description":"## Background\nEvent fetching should be opt-in (default true) so users who don't need temporal queries skip 3 extra API calls per entity. This follows the existing SyncConfig pattern with serde defaults and camelCase JSON aliases.\n\n## Approach\nAdd to SyncConfig in src/core/config.rs:\n```rust\n#[serde(rename = \"fetchResourceEvents\", default = \"default_true\")]\npub fetch_resource_events: bool,\n```\n\nAdd default function (if not already present):\n```rust\nfn default_true() -> bool { true }\n```\n\nUpdate Default impl for SyncConfig to include `fetch_resource_events: true`.\n\nAdd --no-events flag to sync command in src/cli/mod.rs (SyncArgs):\n```rust\n/// Skip resource event fetching (overrides config)\n#[arg(long = \"no-events\", help_heading = \"Sync Options\")]\npub no_events: bool,\n```\n\nIn the sync command handler (src/cli/commands/sync.rs), override config when flag is set:\n```rust\nif args.no_events {\n config.sync.fetch_resource_events = false;\n}\n```\n\n## Acceptance Criteria\n- [ ] SyncConfig deserializes `fetchResourceEvents: false` from JSON config\n- [ ] SyncConfig defaults to `fetch_resource_events: true` when field absent\n- [ ] `--no-events` flag parses correctly in CLI\n- [ ] `--no-events` overrides config to false\n- [ ] `cargo test` passes with no regressions\n\n## Files\n- src/core/config.rs (add field to SyncConfig + default fn + Default impl)\n- src/cli/mod.rs (add --no-events to SyncArgs)\n- src/cli/commands/sync.rs (override config when flag set)\n\n## TDD Loop\nRED: tests/config_tests.rs (or inline in config.rs):\n- `test_sync_config_fetch_resource_events_default_true` - omit field from JSON, verify default\n- `test_sync_config_fetch_resource_events_explicit_false` - set field false, verify parsed\n- `test_sync_config_no_events_flag` - verify CLI arg parsing\n\nGREEN: Add the field, default fn, Default impl update, CLI flag, and override logic\n\nVERIFY: `cargo test config -- --nocapture && cargo build`\n\n## Edge Cases\n- Ensure serde rename matches camelCase convention used by all other SyncConfig fields\n- The default_true fn may already exist for other fields — check before adding duplicate\n- The --no-events flag must NOT be confused with --no-X negation flags already in CLI (check mod.rs for conflicts)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:24.006037Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:10:20.311986Z","closed_at":"2026-02-03T16:10:20.311939Z","close_reason":"Completed: Added fetch_resource_events bool to SyncConfig with serde rename, default_true, --no-events CLI flag, and config override in sync handler","compaction_level":0,"original_size":0,"labels":["config","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-2e8","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2emv","title":"FrankenTUI integration proof + terminal compat smoke test","description":"## Background\nThis is the critical validation that FrankenTUI works with our setup. A minimal Model trait implementation must compile, render a frame, and handle basic input. Terminal compatibility must be verified in iTerm2 and tmux. This proves the toolchain gate before investing in the full implementation.\n\n## Approach\nIn crates/lore-tui/src/app.rs, implement a minimal LoreApp that:\n- implements ftui_runtime::program::Model with type Message = Msg\n- init() returns Cmd::none()\n- update() handles Msg::Quit to return None (exit) and ignores everything else\n- view() renders a simple \"lore TUI\" text centered on screen\n- subscriptions() returns empty vec\n\nAdd a smoke test binary or integration test that:\n- Creates a TerminalSession with ftui test harness\n- Verifies Model::view() produces non-empty output\n- Verifies resize events are handled without panic\n- Tests render in both fullscreen and inline(12) modes\n\nTerminal compat: manually verify ftui demo-showcase renders correctly in iTerm2 and tmux (document results in test notes).\n\n## Acceptance Criteria\n- [ ] LoreApp implements Model trait with Msg as message type\n- [ ] App::fullscreen(lore_app).run() compiles (even if not runnable in CI without a TTY)\n- [ ] App::inline(lore_app, 12).run() compiles\n- [ ] Panic hook installed: terminal restored on crash (crossterm disable_raw_mode + LeaveAlternateScreen)\n- [ ] Crash report written to ~/.local/share/lore/crash-{timestamp}.log with redacted sensitive data\n- [ ] Crash file retention: max 20 files, oldest deleted\n- [ ] ftui demo-showcase renders correctly in iTerm2 (documented)\n- [ ] ftui demo-showcase renders correctly in tmux (documented)\n- [ ] Binary size increase < 5MB over current lore binary\n\n## Files\n- CREATE: crates/lore-tui/src/app.rs (minimal Model impl)\n- MODIFY: crates/lore-tui/src/lib.rs (add install_panic_hook_for_tui, crash report logic)\n- CREATE: crates/lore-tui/src/crash_context.rs (ring buffer stub for crash diagnostics)\n\n## TDD Anchor\nRED: Write test_app_model_compiles that creates LoreApp and calls init(), verifying it returns without error.\nGREEN: Implement minimal LoreApp struct with Model trait.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_app_model\n\n## Edge Cases\n- CI environments have no TTY — tests must use ftui test harness, not actual terminal\n- tmux may not support all ANSI features — FrankenTUI's BOCPD resize coalescing must be verified\n- Panic hook must handle double-panic gracefully (don't panic inside the panic hook)\n- Crash context ring buffer must be lock-free readable from panic hook (signal safety)\n\n## Dependency Context\nUses crate scaffold (Cargo.toml, rust-toolchain.toml) from \"Create lore-tui crate scaffold\" task.\nUses Msg enum and Screen type from \"Implement core types\" task.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:54:52.087021Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:06:53.032980Z","closed_at":"2026-02-12T20:06:53.032792Z","close_reason":"LoreApp Model trait impl: init/update/view + compile-time App::fullscreen/inline assertions. 4 tests, quality gate green.","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2emv","depends_on_id":"bd-3ddw","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2emv","depends_on_id":"bd-c9gk","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} -{"id":"bd-2ez","title":"Add 'lore count references' command","description":"## Background\n\nThe count command currently supports issues, mrs, discussions, notes, and events. This adds 'references' as a new entity type, showing cross-reference totals and breakdowns by reference_type and source_method.\n\n## Codebase Context\n\n- entity_references table (migration 011) with:\n - reference_type CHECK: 'closes' | 'mentioned' | 'related'\n - source_method CHECK: 'api' | 'note_parse' | 'description_parse'\n - target_entity_id: NULL for unresolved cross-project refs\n- Count command pattern in src/cli/commands/count.rs: run_count() returns CountResult, handle_count formats output\n- events count already implemented as a special case: run_count_events() in main.rs (line ~829)\n- count.rs has value_parser list for entity arg\n- 26 migrations exist (001-026). entity_references was introduced in migration 011.\n\n## Approach\n\n### 1. Add to CountArgs value_parser in `src/cli/mod.rs`:\n```rust\n#[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\", \"events\", \"references\"])]\npub entity: String,\n```\n\n### 2. Add types and query in `src/cli/commands/count.rs`:\n\n```rust\npub struct ReferenceCountResult {\n pub total: i64,\n pub by_type: HashMap, // closes, mentioned, related\n pub by_method: HashMap, // api, note_parse, description_parse\n pub unresolved: i64,\n}\n```\n\n### 3. SQL (single conditional aggregate query — no N+1):\n```sql\nSELECT\n COUNT(*) as total,\n COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0) as closes,\n COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0) as mentioned,\n COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0) as related,\n COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0) as api,\n COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0) as note_parse,\n COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0) as desc_parse,\n COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) as unresolved\nFROM entity_references\n```\n\n### 4. Human output:\n```\nReferences: 1,234\n By type:\n closes: 456\n mentioned: 678\n related: 100\n By source:\n api: 234\n note_parse: 890\n description_parse: 110\n Unresolved: 45 (3.6%)\n```\n\n### 5. Robot JSON:\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": \"references\",\n \"total\": 1234,\n \"by_type\": { \"closes\": 456, \"mentioned\": 678, \"related\": 100 },\n \"by_method\": { \"api\": 234, \"note_parse\": 890, \"description_parse\": 110 },\n \"unresolved\": 45\n }\n}\n```\n\n### 6. Wire in main.rs handle_count:\nAdd \"references\" branch, similar to the existing \"events\" special case.\n\n## Acceptance Criteria\n\n- [ ] `lore count references` works with human output\n- [ ] `lore --robot count references` returns JSON with {ok, data, meta} envelope\n- [ ] by_type uses codebase values: closes, mentioned, related\n- [ ] by_method uses codebase values: api, note_parse, description_parse\n- [ ] Unresolved = COUNT WHERE target_entity_id IS NULL\n- [ ] Zero references: all counts 0, not error\n- [ ] entity_references table missing (pre-migration-011 schema): graceful error with migration suggestion\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] `cargo fmt --check` passes\n\n## Files\n\n- MODIFY: src/cli/mod.rs (add \"references\" to value_parser list)\n- MODIFY: src/cli/commands/count.rs (add count_references() + ReferenceCountResult)\n- MODIFY: src/main.rs (add \"references\" branch in handle_count)\n\n## TDD Anchor\n\nRED: test_count_references_query — in-memory DB with migration 011+, insert 3 entity_references rows (one closes/api, one mentioned/note_parse, one related/api with target_entity_id=NULL), verify all counts.\n\nGREEN: Implement query, result type, output formatters.\n\nVERIFY: cargo test --lib -- count && cargo check --all-targets\n\n## Edge Cases\n\n- entity_references table doesn't exist (pre-migration-011): catch SQL error, return user-friendly message suggesting `lore sync`\n- All references unresolved: unresolved = total, percentage = 100%\n- Division by zero in percentage: guard with `if total > 0`\n- New reference_type/source_method values added in future: they won't appear in breakdown but will be in total — consider logging unknown values\n\n## Dependency Context\n\n- **bd-hu3 / migration 011**: provides the entity_references table with reference_type and source_method CHECK constraints. This bead reads from that table — no writes.\n- **count.rs pattern**: run_count() dispatches to entity-specific queries. events already has a special-case function run_count_events() — follow the same pattern for references.","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-02T22:42:43.780303Z","created_by":"tayloreernisse","updated_at":"2026-02-17T16:52:59.706810Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ez","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-2ez","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} +{"id":"bd-2ez","title":"Add 'lore count references' command","description":"## Background\n\nThe count command currently supports issues, mrs, discussions, notes, and events. This adds 'references' as a new entity type, showing cross-reference totals and breakdowns by reference_type and source_method.\n\n## Codebase Context\n\n- entity_references table (migration 011) with:\n - reference_type CHECK: 'closes' | 'mentioned' | 'related'\n - source_method CHECK: 'api' | 'note_parse' | 'description_parse'\n - target_entity_id: NULL for unresolved cross-project refs\n- Count command pattern in src/cli/commands/count.rs: run_count() returns CountResult, handle_count formats output\n- events count already implemented as a special case: run_count_events() in main.rs (line ~829)\n- count.rs has value_parser list for entity arg\n- 26 migrations exist (001-026). entity_references was introduced in migration 011.\n\n## Approach\n\n### 1. Add to CountArgs value_parser in `src/cli/mod.rs`:\n```rust\n#[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\", \"events\", \"references\"])]\npub entity: String,\n```\n\n### 2. Add types and query in `src/cli/commands/count.rs`:\n\n```rust\npub struct ReferenceCountResult {\n pub total: i64,\n pub by_type: HashMap, // closes, mentioned, related\n pub by_method: HashMap, // api, note_parse, description_parse\n pub unresolved: i64,\n}\n```\n\n### 3. SQL (single conditional aggregate query — no N+1):\n```sql\nSELECT\n COUNT(*) as total,\n COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0) as closes,\n COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0) as mentioned,\n COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0) as related,\n COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0) as api,\n COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0) as note_parse,\n COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0) as desc_parse,\n COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) as unresolved\nFROM entity_references\n```\n\n### 4. Human output:\n```\nReferences: 1,234\n By type:\n closes: 456\n mentioned: 678\n related: 100\n By source:\n api: 234\n note_parse: 890\n description_parse: 110\n Unresolved: 45 (3.6%)\n```\n\n### 5. Robot JSON:\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": \"references\",\n \"total\": 1234,\n \"by_type\": { \"closes\": 456, \"mentioned\": 678, \"related\": 100 },\n \"by_method\": { \"api\": 234, \"note_parse\": 890, \"description_parse\": 110 },\n \"unresolved\": 45\n }\n}\n```\n\n### 6. Wire in main.rs handle_count:\nAdd \"references\" branch, similar to the existing \"events\" special case.\n\n## Acceptance Criteria\n\n- [ ] `lore count references` works with human output\n- [ ] `lore --robot count references` returns JSON with {ok, data, meta} envelope\n- [ ] by_type uses codebase values: closes, mentioned, related\n- [ ] by_method uses codebase values: api, note_parse, description_parse\n- [ ] Unresolved = COUNT WHERE target_entity_id IS NULL\n- [ ] Zero references: all counts 0, not error\n- [ ] entity_references table missing (pre-migration-011 schema): graceful error with migration suggestion\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] `cargo fmt --check` passes\n\n## Files\n\n- MODIFY: src/cli/mod.rs (add \"references\" to value_parser list)\n- MODIFY: src/cli/commands/count.rs (add count_references() + ReferenceCountResult)\n- MODIFY: src/main.rs (add \"references\" branch in handle_count)\n\n## TDD Anchor\n\nRED: test_count_references_query — in-memory DB with migration 011+, insert 3 entity_references rows (one closes/api, one mentioned/note_parse, one related/api with target_entity_id=NULL), verify all counts.\n\nGREEN: Implement query, result type, output formatters.\n\nVERIFY: cargo test --lib -- count && cargo check --all-targets\n\n## Edge Cases\n\n- entity_references table doesn't exist (pre-migration-011): catch SQL error, return user-friendly message suggesting `lore sync`\n- All references unresolved: unresolved = total, percentage = 100%\n- Division by zero in percentage: guard with `if total > 0`\n- New reference_type/source_method values added in future: they won't appear in breakdown but will be in total — consider logging unknown values\n\n## Dependency Context\n\n- **bd-hu3 / migration 011**: provides the entity_references table with reference_type and source_method CHECK constraints. This bead reads from that table — no writes.\n- **count.rs pattern**: run_count() dispatches to entity-specific queries. events already has a special-case function run_count_events() — follow the same pattern for references.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T22:42:43.780303Z","created_by":"tayloreernisse","updated_at":"2026-02-19T14:00:58.197033Z","closed_at":"2026-02-19T14:00:58.196968Z","close_reason":"Implemented lore count references with by_type/by_method breakdowns, unresolved count, human + robot output, 2 unit tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ez","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-2ez","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} {"id":"bd-2ezb","title":"NOTE-2D: Regenerator and dirty tracking for note documents","description":"## Background\nWire note document extraction into the regenerator and add change-aware dirty marking in the ingestion pipeline. When a note's semantic content changes during upsert, it gets queued for document regeneration.\n\n## Approach\n1. Update regenerate_one() in src/documents/regenerator.rs (line 86-91):\n Add match arm: SourceType::Note => extract_note_document(conn, source_id)?\n Add import: use crate::documents::extract_note_document;\n This replaces the temporary unreachable!() from NOTE-2B.\n\n2. Add change-aware dirty marking in src/ingestion/discussions.rs (in upsert loop modified by NOTE-0A):\n After each upsert_note_for_issue call:\n if !note.is_system && outcome.changed_semantics {\n dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, outcome.local_note_id)?;\n }\n Import: use crate::documents::SourceType;\n\n3. Same in src/ingestion/mr_discussions.rs for MR note upserts (after upsert_note call near line 470 area, once NOTE-0A modifies it to return NoteUpsertOutcome).\n\n4. Update test setup helpers:\n - src/documents/regenerator.rs tests: the setup_db() function creates test tables. Add notes + discussions tables so regenerate_one can be tested with SourceType::Note. Also update the dirty_sources CHECK constraint in test setup to include 'note'.\n - src/ingestion/dirty_tracker.rs tests: similar test setup_db() update for CHECK constraint.\n\n## Files\n- MODIFY: src/documents/regenerator.rs (add Note match arm at line 90, add import, update test setup_db)\n- MODIFY: src/ingestion/discussions.rs (add dirty marking after upsert loop)\n- MODIFY: src/ingestion/mr_discussions.rs (add dirty marking after upsert)\n- MODIFY: src/ingestion/dirty_tracker.rs (update test setup_db CHECK constraint if present)\n\n## TDD Anchor\nRED: test_regenerate_note_document — create project, issue, discussion, note, mark dirty, call regenerate_dirty_documents, assert document created with source_type='note'.\nGREEN: Add SourceType::Note arm to regenerate_one.\nVERIFY: cargo test regenerate_note_document -- --nocapture\nTests: test_regenerate_note_system_note_deletes (system note in dirty queue → document gets deleted), test_regenerate_note_unchanged (same content hash → no update), test_note_ingestion_idempotent_across_two_syncs (identical re-sync produces no new dirty entries), test_mark_dirty_note_type\n\n## Acceptance Criteria\n- [ ] regenerate_one() handles SourceType::Note via extract_note_document\n- [ ] Changed notes queued as dirty during issue discussion ingestion\n- [ ] Changed notes queued as dirty during MR discussion ingestion\n- [ ] System notes never queued as dirty (is_system guard)\n- [ ] Unchanged notes not re-queued (changed_semantics = false from NOTE-0A)\n- [ ] Second sync of identical data produces no new dirty entries\n- [ ] All 5 tests pass\n\n## Dependency Context\n- Depends on NOTE-0A (bd-3bpk): uses NoteUpsertOutcome.changed_semantics from upsert functions\n- Depends on NOTE-2B (bd-ef0u): SourceType::Note enum variant for dirty marking and match arm\n- Depends on NOTE-2C (bd-18yh): extract_note_document function for the regenerator dispatch\n\n## Edge Cases\n- Note deleted during regeneration: extract_note_document returns None → delete_document called (line 93-95 of regenerator.rs)\n- System note in dirty queue (from manual INSERT): extract returns None → document deleted\n- Concurrent sync + regeneration: dirty_tracker uses ON CONFLICT handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:14.161688Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:23.852811Z","closed_at":"2026-02-12T18:13:23.852765Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-2ezb","depends_on_id":"bd-22uw","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ezb","depends_on_id":"bd-3o0i","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ezb","depends_on_id":"bd-9wl5","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2f0","title":"[CP1] gi count issues/discussions/notes commands","description":"## Background\n\nThe `gi count` command provides quick counts of entities in the local database. It supports counting issues, MRs, discussions, and notes, with optional filtering by noteable type. This enables quick validation that sync is working correctly.\n\n## Approach\n\n### Module: src/cli/commands/count.rs\n\n### Clap Definition\n\n```rust\n#[derive(Args)]\npub struct CountArgs {\n /// Entity type to count\n #[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\"])]\n pub entity: String,\n\n /// Filter by noteable type (for discussions/notes)\n #[arg(long, value_parser = [\"issue\", \"mr\"])]\n pub r#type: Option,\n}\n```\n\n### Handler Function\n\n```rust\npub async fn handle_count(args: CountArgs, conn: &Connection) -> Result<()>\n```\n\n### Queries by Entity\n\n**issues:**\n```sql\nSELECT COUNT(*) FROM issues\n```\nOutput: `Issues: 3,801`\n\n**discussions:**\n```sql\n-- Without type filter\nSELECT COUNT(*) FROM discussions\n\n-- With --type=issue\nSELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'\n```\nOutput: `Issue Discussions: 1,234`\n\n**notes:**\n```sql\n-- Total and system count\nSELECT COUNT(*), SUM(is_system) FROM notes\n\n-- With --type=issue (join through discussions)\nSELECT COUNT(*), SUM(n.is_system)\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue'\n```\nOutput: `Issue Notes: 5,678 (excluding 1,234 system)`\n\n### Output Format\n\n```\nIssues: 3,801\n```\n\n```\nIssue Discussions: 1,234\n```\n\n```\nIssue Notes: 5,678 (excluding 1,234 system)\n```\n\n## Acceptance Criteria\n\n- [ ] `gi count issues` shows total issue count\n- [ ] `gi count discussions` shows total discussion count\n- [ ] `gi count discussions --type=issue` filters to issue discussions\n- [ ] `gi count notes` shows total note count with system note exclusion\n- [ ] `gi count notes --type=issue` filters to issue notes\n- [ ] Numbers formatted with thousands separators (1,234)\n\n## Files\n\n- src/cli/commands/mod.rs (add `pub mod count;`)\n- src/cli/commands/count.rs (create)\n- src/cli/mod.rs (add Count variant to Commands enum)\n\n## TDD Loop\n\nRED:\n```rust\n#[tokio::test] async fn count_issues_returns_total()\n#[tokio::test] async fn count_discussions_with_type_filter()\n#[tokio::test] async fn count_notes_excludes_system_notes()\n```\n\nGREEN: Implement handler with queries\n\nVERIFY: `cargo test count`\n\n## Edge Cases\n\n- Zero entities - show \"Issues: 0\"\n- --type flag invalid for issues/mrs - ignore or error\n- All notes are system notes - show \"Notes: 0 (excluding 1,234 system)\"","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.360495Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:01:37.084627Z","closed_at":"2026-01-25T23:01:37.084568Z","close_reason":"Implemented gi count command with issues/discussions/notes support, format_number helper, and system note exclusion","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2f0","depends_on_id":"bd-208","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2f2","title":"Implement timeline human output renderer","description":"## Background\n\nThis bead implements the human-readable (non-robot) output renderer for `lore timeline`. It takes a collection of TimelineEvents and renders them as a colored, chronological timeline in the terminal.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.4 (Human Output Format).\n\n## Codebase Context\n\n- Colored output pattern: src/cli/commands/show.rs uses `colored` crate for terminal styling\n- Existing formatters: `print_show_issue()`, `print_show_mr()`, `print_list_issues()`\n- TimelineEvent model (bd-20e): timestamp, entity_type, entity_iid, project_path, event_type, summary, actor, url, is_seed\n- TimelineEventType enum (bd-20e): Created, StateChanged, LabelAdded, LabelRemoved, MilestoneSet, MilestoneRemoved, Merged, NoteEvidence, CrossReferenced\n- Expansion provenance: expanded entities have `via` info (from which seed, what edge type)\n- Convention: all output functions take `&[TimelineEvent]` and metadata, not raw DB results\n\n## Approach\n\nCreate `src/cli/commands/timeline.rs`:\n\n```rust\nuse colored::Colorize;\nuse crate::core::timeline::{TimelineEvent, TimelineEventType, TimelineQueryResult};\n\npub fn print_timeline(result: &TimelineQueryResult) {\n // Header\n println\\!();\n println\\!(\"{}\", format\\!(\"Timeline: \\\"{}\\\" ({} events across {} entities)\",\n result.query, result.events.len(), result.total_entities).bold());\n println\\!(\"{}\", \"─\".repeat(60));\n println\\!();\n\n // Events\n for event in &result.events {\n print_timeline_event(event);\n }\n\n // Footer\n println\\!();\n println\\!(\"{}\", \"─\".repeat(60));\n print_timeline_footer(result);\n}\n\nfn print_timeline_event(event: &TimelineEvent) {\n let date = format_date(event.timestamp);\n let tag = format_event_tag(&event.event_type);\n let entity = format_entity_ref(event.entity_type.as_str(), event.entity_iid);\n let actor = event.actor.as_deref().map(|a| format\\!(\"@{a}\")).unwrap_or_default();\n let expanded_marker = if event.is_seed { \"\" } else { \" [expanded]\" };\n\n println\\!(\"{date} {tag:10} {entity:6} {summary:40} {actor}{expanded_marker}\",\n summary = &event.summary);\n\n // Extra lines for specific event types\n match &event.event_type {\n TimelineEventType::NoteEvidence { snippet, .. } => {\n // Show snippet indented, wrapped to ~70 chars\n for line in wrap_text(snippet, 70) {\n println\\!(\" \\\"{line}\\\"\");\n }\n }\n TimelineEventType::Created => {\n // Could show labels if available in details\n }\n _ => {}\n }\n}\n```\n\n### Event Tag Colors:\n| Tag | Color |\n|-----|-------|\n| CREATED | green |\n| CLOSED | red |\n| REOPENED | yellow |\n| MERGED | cyan |\n| LABEL | blue |\n| MILESTONE | magenta |\n| NOTE | white/dim |\n| REF | dim |\n\n### Date Format:\n```\n2024-03-15 CREATED #234 Migrate to OAuth2 @alice\n```\nUse `YYYY-MM-DD` for dates. Group consecutive same-day events visually.\n\nAdd `pub mod timeline;` to `src/cli/commands/mod.rs` and re-export `print_timeline`.\n\n## Acceptance Criteria\n\n- [ ] `print_timeline()` renders header with query, event count, entity count\n- [ ] Events displayed chronologically with: date, tag, entity ref, summary, actor\n- [ ] Expanded entities marked with [expanded] suffix\n- [ ] NoteEvidence events show snippet text indented and quoted\n- [ ] Tags colored by event type\n- [ ] Footer shows seed entities and expansion info\n- [ ] Module registered in src/cli/commands/mod.rs\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/commands/timeline.rs` (NEW)\n- `src/cli/commands/mod.rs` (add `pub mod timeline;` and re-export `print_timeline`)\n\n## TDD Loop\n\nNo unit tests for terminal rendering. Verify visually:\n\n```bash\ncargo check --all-targets\n# After full pipeline: lore timeline \"some query\"\n```\n\n## Edge Cases\n\n- Empty result: print \"No events found for query.\" and exit 0\n- Very long summaries: truncate to 60 chars with \"...\"\n- NoteEvidence snippets: wrap at 70 chars, cap at 4 lines\n- Null actors (system events): show no @username\n- Entity types: # for issues, \\! for MRs (GitLab convention)\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.326026Z","created_by":"tayloreernisse","updated_at":"2026-02-06T13:49:10.580508Z","closed_at":"2026-02-06T13:49:10.580438Z","close_reason":"Implemented print_timeline() human renderer in src/cli/commands/timeline.rs with colored chronological output, event tags, entity refs, evidence note snippets, and footer summary","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-2f2","depends_on_id":"bd-3as","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2f2","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} diff --git a/.beads/last-touched b/.beads/last-touched index a646e0c..1a45c25 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -bd-8con +bd-2ez diff --git a/src/cli/autocorrect.rs b/src/cli/autocorrect.rs index 490d300..76311be 100644 --- a/src/cli/autocorrect.rs +++ b/src/cli/autocorrect.rs @@ -287,6 +287,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[ ), ("show", &["--project"]), ("reset", &["--yes"]), + ("related", &["--limit", "--project"]), ]; /// Valid values for enum-like flags, used for post-clap error enhancement. diff --git a/src/cli/commands/count.rs b/src/cli/commands/count.rs index 10095ed..5971907 100644 --- a/src/cli/commands/count.rs +++ b/src/cli/commands/count.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::cli::render::{self, Theme}; use rusqlite::Connection; use serde::Serialize; @@ -211,6 +213,78 @@ pub fn run_count_events(config: &Config) -> Result { events_db::count_events(&conn) } +// --------------------------------------------------------------------------- +// References count +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +pub struct ReferenceCountResult { + pub total: i64, + pub by_type: HashMap, + pub by_method: HashMap, + pub unresolved: i64, +} + +pub fn run_count_references(config: &Config) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + count_references(&conn) +} + +fn count_references(conn: &Connection) -> Result { + let (total, closes, mentioned, related, api, note_parse, desc_parse, unresolved): ( + i64, + i64, + i64, + i64, + i64, + i64, + i64, + i64, + ) = conn.query_row( + "SELECT + COUNT(*) AS total, + COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) + FROM entity_references", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }, + )?; + + let mut by_type = HashMap::new(); + by_type.insert("closes".to_string(), closes); + by_type.insert("mentioned".to_string(), mentioned); + by_type.insert("related".to_string(), related); + + let mut by_method = HashMap::new(); + by_method.insert("api".to_string(), api); + by_method.insert("note_parse".to_string(), note_parse); + by_method.insert("description_parse".to_string(), desc_parse); + + Ok(ReferenceCountResult { + total, + by_type, + by_method, + unresolved, + }) +} + #[derive(Serialize)] struct EventCountJsonOutput { ok: bool, @@ -363,6 +437,77 @@ pub fn print_count(result: &CountResult) { } } +// --------------------------------------------------------------------------- +// References output +// --------------------------------------------------------------------------- + +pub fn print_reference_count(result: &ReferenceCountResult) { + println!( + "{}: {:>10}", + Theme::info().render("References"), + Theme::bold().render(&render::format_number(result.total)) + ); + + println!(" By type:"); + for key in &["closes", "mentioned", "related"] { + let val = result.by_type.get(*key).copied().unwrap_or(0); + println!(" {:<20} {:>10}", key, render::format_number(val)); + } + + println!(" By source:"); + for key in &["api", "note_parse", "description_parse"] { + let val = result.by_method.get(*key).copied().unwrap_or(0); + println!(" {:<20} {:>10}", key, render::format_number(val)); + } + + let pct = if result.total > 0 { + format!( + " ({:.1}%)", + result.unresolved as f64 / result.total as f64 * 100.0 + ) + } else { + String::new() + }; + + println!( + " Unresolved: {:>10}{}", + render::format_number(result.unresolved), + Theme::dim().render(&pct) + ); +} + +#[derive(Serialize)] +struct RefCountJsonOutput { + ok: bool, + data: RefCountJsonData, + meta: RobotMeta, +} + +#[derive(Serialize)] +struct RefCountJsonData { + entity: String, + total: i64, + by_type: HashMap, + by_method: HashMap, + unresolved: i64, +} + +pub fn print_reference_count_json(result: &ReferenceCountResult, elapsed_ms: u64) { + let output = RefCountJsonOutput { + ok: true, + data: RefCountJsonData { + entity: "references".to_string(), + total: result.total, + by_type: result.by_type.clone(), + by_method: result.by_method.clone(), + unresolved: result.unresolved, + }, + meta: RobotMeta { elapsed_ms }, + }; + + println!("{}", serde_json::to_string(&output).unwrap_or_default()); +} + #[cfg(test)] mod tests { use crate::cli::render; @@ -381,4 +526,99 @@ mod tests { assert_eq!(render::format_number(12345), "12,345"); assert_eq!(render::format_number(1234567), "1,234,567"); } + + #[test] + fn test_count_references_query() { + use std::path::Path; + + use crate::core::db::{create_connection, run_migrations}; + + use super::count_references; + + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + + // Insert 3 entity_references rows with varied types/methods. + // First need a project row to satisfy FK. + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) + VALUES (1, 100, 'g/test', 'https://git.example.com/g/test')", + [], + ) + .unwrap(); + + // Need source entities for the FK. + conn.execute( + "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at) + VALUES (1, 200, 1, 1, 'Issue 1', 'opened', 0, 0, 0)", + [], + ) + .unwrap(); + + // Row 1: closes / api / resolved (target_entity_id = 1) + conn.execute( + "INSERT INTO entity_references + (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, + reference_type, source_method, created_at) + VALUES (1, 'issue', 1, 'issue', 1, 'closes', 'api', 1000)", + [], + ) + .unwrap(); + + // Row 2: mentioned / note_parse / unresolved (target_entity_id = NULL) + conn.execute( + "INSERT INTO entity_references + (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, + target_project_path, target_entity_iid, + reference_type, source_method, created_at) + VALUES (1, 'issue', 1, 'merge_request', NULL, 'other/proj', 42, 'mentioned', 'note_parse', 2000)", + [], + ) + .unwrap(); + + // Row 3: related / api / unresolved (target_entity_id = NULL) + conn.execute( + "INSERT INTO entity_references + (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, + target_project_path, target_entity_iid, + reference_type, source_method, created_at) + VALUES (1, 'issue', 1, 'issue', NULL, 'other/proj2', 99, 'related', 'api', 3000)", + [], + ) + .unwrap(); + + let result = count_references(&conn).unwrap(); + + assert_eq!(result.total, 3); + assert_eq!(*result.by_type.get("closes").unwrap(), 1); + assert_eq!(*result.by_type.get("mentioned").unwrap(), 1); + assert_eq!(*result.by_type.get("related").unwrap(), 1); + assert_eq!(*result.by_method.get("api").unwrap(), 2); + assert_eq!(*result.by_method.get("note_parse").unwrap(), 1); + assert_eq!(*result.by_method.get("description_parse").unwrap(), 0); + assert_eq!(result.unresolved, 2); + } + + #[test] + fn test_count_references_empty_table() { + use std::path::Path; + + use crate::core::db::{create_connection, run_migrations}; + + use super::count_references; + + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + + let result = count_references(&conn).unwrap(); + + assert_eq!(result.total, 0); + assert_eq!(*result.by_type.get("closes").unwrap(), 0); + assert_eq!(*result.by_type.get("mentioned").unwrap(), 0); + assert_eq!(*result.by_type.get("related").unwrap(), 0); + assert_eq!(*result.by_method.get("api").unwrap(), 0); + assert_eq!(*result.by_method.get("note_parse").unwrap(), 0); + assert_eq!(*result.by_method.get("description_parse").unwrap(), 0); + assert_eq!(result.unresolved, 0); + } } diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index d5d7be7..4b44a20 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -22,8 +22,9 @@ pub mod who; pub use auth_test::run_auth_test; pub use count::{ - print_count, print_count_json, print_event_count, print_event_count_json, run_count, - run_count_events, + print_count, print_count_json, print_event_count, print_event_count_json, + print_reference_count, print_reference_count_json, run_count, run_count_events, + run_count_references, }; pub use doctor::{DoctorChecks, print_doctor_results, run_doctor}; pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift}; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index eaa3f47..5309b00 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1102,8 +1102,8 @@ pub struct RelatedArgs { #[derive(Parser)] pub struct CountArgs { - /// Entity type to count (issues, mrs, discussions, notes, events) - #[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])] + /// Entity type to count (issues, mrs, discussions, notes, events, references) + #[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events", "references"])] pub entity: String, /// Parent type filter: issue or mr (for discussions/notes) diff --git a/src/main.rs b/src/main.rs index 5274a77..43ce653 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,12 +17,13 @@ use lore::cli::commands::{ print_event_count_json, print_file_history, print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json, print_list_notes, - print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_related, - print_related_json, print_search_results, print_search_results_json, print_show_issue, - print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json, - print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline, - print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json, - query_notes, run_auth_test, run_count, run_count_events, run_doctor, run_drift, run_embed, + print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_reference_count, + print_reference_count_json, print_related, print_related_json, print_search_results, + print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr, + print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json, + print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta, + print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test, + run_count, run_count_events, run_count_references, run_doctor, run_drift, run_embed, run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs, run_related, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, run_tui, run_who, @@ -1224,6 +1225,16 @@ async fn handle_count( return Ok(()); } + if args.entity == "references" { + let result = run_count_references(&config)?; + if robot_mode { + print_reference_count_json(&result, start.elapsed().as_millis() as u64); + } else { + print_reference_count(&result); + } + return Ok(()); + } + let result = run_count(&config, &args.entity, args.for_entity.as_deref())?; if robot_mode { print_count_json(&result, start.elapsed().as_millis() as u64);