From 20db46a514988181438261b209123c4c18cf900e Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 19 Feb 2026 09:44:29 -0500 Subject: [PATCH] refactor: split who.rs into who/ module (bd-2cbw) Split 2447-line who.rs into focused submodules: - who/scoring.rs: half_life_decay (20 lines) - who/queries.rs: 5 query functions + helpers (~1400 lines) - who/format.rs: human + JSON formatters (~570 lines) - who.rs: slim module root with mode dispatch + re-exports (~260 lines) All 1052 tests pass. No public API changes. --- .beads/issues.jsonl | 4 +- .beads/last-touched | 2 +- src/cli/commands/who.rs | 2174 +------------------------------ src/cli/commands/who/format.rs | 2 +- src/cli/commands/who/queries.rs | 4 +- 5 files changed, 36 insertions(+), 2150 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e3f588a..867a43e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -58,7 +58,7 @@ {"id":"bd-1mf","title":"[CP1] gi sync-status enhancement","description":"Enhance sync-status from CP0 stub to show issue cursors.\n\nOutput:\n- Last run timestamp and duration\n- Cursor positions per project (issues resource_type)\n- Entity counts (issues, discussions, notes)\n\nFiles: src/cli/commands/sync-status.ts (update existing)\nDone when: Shows cursor positions and counts after ingestion","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:36.449088Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.157235Z","closed_at":"2026-01-25T15:21:35.157235Z","deleted_at":"2026-01-25T15:21:35.157232Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-1mju","title":"Vertical slice integration test + SLO verification","description":"## Background\nThe vertical slice gate validates that core screens work together end-to-end with real data flows and meet performance SLOs. This is a manual + automated verification pass.\n\n## Approach\nCreate integration tests in crates/lore-tui/tests/:\n- test_full_nav_flow: Dashboard -> press i -> IssueList loads -> press Enter -> IssueDetail loads -> press Esc -> back to IssueList with cursor preserved -> press H -> Dashboard\n- test_filter_requery: IssueList -> type filter -> verify re-query triggers and results update\n- test_stale_result_guard: rapidly navigate between screens, verify no stale data displayed\n- Performance benchmarks: run M-tier fixture, measure p95 nav latency, assert < 75ms\n- Stuck-input check: fuzz InputMode transitions, assert always recoverable via Esc or Ctrl+C\n- Cancel latency: start sync, cancel, measure time to acknowledgment, assert < 2s\n\n## Acceptance Criteria\n- [ ] Full nav flow test passes without panic\n- [ ] Filter re-query test shows updated results\n- [ ] No stale data displayed during rapid navigation\n- [ ] p95 nav latency < 75ms on M-tier fixtures\n- [ ] Zero stuck-input states across 1000 random key sequences\n- [ ] Sync cancel acknowledged p95 < 2s\n- [ ] All state preserved correctly on back-navigation\n\n## Files\n- CREATE: crates/lore-tui/tests/vertical_slice.rs\n\n## TDD Anchor\nRED: Write test_dashboard_to_issue_detail_roundtrip that navigates Dashboard -> IssueList -> IssueDetail -> Esc -> IssueList, asserts cursor position preserved.\nGREEN: Ensure all navigation and state preservation is wired up.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml vertical_slice\n\n## Edge Cases\n- Tests need FakeClock and synthetic DB fixtures (not real GitLab)\n- ftui test harness required for rendering tests without TTY\n- Performance benchmarks may vary by machine — use relative thresholds\n\n## Dependency Context\nRequires all Phase 2 screens: Dashboard, Issue List, Issue Detail, MR List, MR Detail.\nRequires NavigationStack, TaskSupervisor, DbManager from Phase 1.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:18.310264Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:06:21.021705Z","closed_at":"2026-02-18T21:06:21.021656Z","close_reason":"Vertical slice integration test complete: 11 tests covering nav flows, stale guards, input fuzz, bootstrap transition, render all screens, performance smoke","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-1mju","depends_on_id":"bd-3t1b","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1mju","depends_on_id":"bd-3ty8","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1mju","depends_on_id":"bd-8ab7","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-1n5","title":"[CP1] gi ingest --type=issues command","description":"CLI command to orchestrate issue ingestion.\n\nImplementation:\n1. Acquire app lock with heartbeat\n2. Create sync_run record (status='running')\n3. For each configured project:\n - Call ingestIssues()\n - For each ingested issue, call ingestIssueDiscussions()\n - Show progress (spinner or progress bar)\n4. Update sync_run (status='succeeded', metrics_json)\n5. Release lock\n\nFlags:\n- --type=issues (required)\n- --project=PATH (optional, filter to single project)\n- --force (override stale lock)\n\nOutput: Progress bar, then summary with counts\n\nFiles: src/cli/commands/ingest.ts\nTests: tests/integration/sync-runs.test.ts\nDone when: Full issue + discussion ingestion works end-to-end","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:20:05.114751Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.153598Z","closed_at":"2026-01-25T15:21:35.153598Z","deleted_at":"2026-01-25T15:21:35.153595Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-1n5q","title":"lore brief: situational awareness for topic/module/person","description":"## Background\nComposable capstone command. An agent says \"I am about to work on auth\" and gets everything in one call: open issues, active MRs, experts, recent activity, unresolved threads, related context. Replaces 5 separate lore calls with 1.\n\n## Input Modes\n1. Topic: `lore brief 'authentication'` — FTS search to find relevant entities, aggregate\n2. Path: `lore brief --path src/auth/` — who expert internals for path expertise\n3. Person: `lore brief --person teernisse` — who workload internals\n4. Entity: `lore brief issues 3864` — single entity focus with cross-references\n\n## Section Assembly Architecture\n\n### Reuse existing run_* functions (ship faster, recommended for v1)\nEach section calls existing CLI command functions and converts their output.\n\nIMPORTANT: All existing run_* functions take `&Config`, NOT `&Connection`. The Config contains the db_path and each function opens its own connection internally.\n\n```rust\n// In src/cli/commands/brief.rs\n\nuse crate::cli::commands::list::{run_list_issues, run_list_mrs, ListFilters, MrListFilters};\nuse crate::cli::commands::who::{run_who, WhoArgs, WhoMode};\nuse crate::core::config::Config;\n\npub async fn run_brief(config: &Config, args: BriefArgs) -> Result {\n let mut sections_computed = Vec::new();\n\n // 1. open_issues: reuse list.rs\n // Signature: pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result\n // Located at src/cli/commands/list.rs:268\n let open_issues = run_list_issues(config, ListFilters {\n state: Some(\"opened\".into()),\n limit: Some(5),\n project: args.project.clone(),\n // ... scope by topic/path/person based on mode\n ..Default::default()\n })?;\n sections_computed.push(\"open_issues\");\n\n // 2. active_mrs: reuse list.rs\n // Signature: pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result\n // Located at src/cli/commands/list.rs:476\n let active_mrs = run_list_mrs(config, MrListFilters {\n state: Some(\"opened\".into()),\n limit: Some(5),\n project: args.project.clone(),\n ..Default::default()\n })?;\n sections_computed.push(\"active_mrs\");\n\n // 3. experts: reuse who.rs\n // Signature: pub fn run_who(config: &Config, args: &WhoArgs) -> Result\n // Located at src/cli/commands/who.rs:276\n let experts = run_who(config, &WhoArgs {\n mode: WhoMode::Expert,\n path: args.path.clone(),\n limit: Some(3),\n ..Default::default()\n })?;\n sections_computed.push(\"experts\");\n\n // 4. recent_activity: reuse timeline internals\n // The timeline pipeline is 5-stage (SEED->HYDRATE->EXPAND->COLLECT->RENDER)\n // Types in src/core/timeline.rs, seed in src/core/timeline_seed.rs\n // ...etc\n}\n```\n\nNOTE: ListFilters and MrListFilters may not implement Default. Check before using `..Default::default()`. If they don't, derive it or construct all fields explicitly.\n\n### Concrete Function References (src/cli/commands/)\n| Module | Function | Signature | Line |\n|--------|----------|-----------|------|\n| list.rs | run_list_issues | `(config: &Config, filters: ListFilters) -> Result` | 268 |\n| list.rs | run_list_mrs | `(config: &Config, filters: MrListFilters) -> Result` | 476 |\n| who.rs | run_who | `(config: &Config, args: &WhoArgs) -> Result` | 276 |\n| search.rs | run_search | `(config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool) -> Result` | 61 |\n\nNOTE: run_search is currently synchronous (pub fn, not pub async fn). If bd-1ksf ships first, it becomes async. Brief should handle both cases — call `.await` if async, direct call if sync.\n\n### Section Details\n| Section | Source | Limit | Fallback |\n|---------|--------|-------|----------|\n| open_issues | list.rs with state=opened | 5 | empty array |\n| active_mrs | list.rs with state=opened | 5 | empty array |\n| experts | who.rs Expert mode | 3 | empty array (no path data) |\n| recent_activity | timeline pipeline | 10 events | empty array |\n| unresolved_threads | SQL: discussions WHERE resolved=false | 5 | empty array |\n| related | search_vector() via bd-8con | 5 | omit section (no embeddings) |\n| warnings | computed from dates/state | all | empty array |\n\n### Warning Generation\n```rust\nfn compute_warnings(issues: &[IssueRow]) -> Vec {\n let now = chrono::Utc::now();\n issues.iter().filter_map(|i| {\n let updated = parse_timestamp(i.updated_at)?;\n let days_stale = (now - updated).num_days();\n if days_stale > 30 {\n Some(format!(\"Issue #{} has no activity for {} days\", i.iid, days_stale))\n } else { None }\n }).chain(\n issues.iter().filter(|i| i.assignees.is_empty())\n .map(|i| format!(\"Issue #{} is unassigned\", i.iid))\n ).collect()\n}\n```\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"mode\": \"topic\",\n \"query\": \"authentication\",\n \"summary\": \"3 open issues, 2 active MRs, top expert: teernisse\",\n \"open_issues\": [{ \"iid\": 123, \"title\": \"...\", \"state\": \"opened\", \"assignees\": [...], \"updated_at\": \"...\", \"labels\": [...] }],\n \"active_mrs\": [{ \"iid\": 456, \"title\": \"...\", \"state\": \"opened\", \"author\": \"...\", \"draft\": false, \"updated_at\": \"...\" }],\n \"experts\": [{ \"username\": \"teernisse\", \"score\": 42, \"last_activity\": \"...\" }],\n \"recent_activity\": [{ \"timestamp\": \"...\", \"event_type\": \"state_change\", \"entity_ref\": \"issues#123\", \"summary\": \"...\", \"actor\": \"...\" }],\n \"unresolved_threads\": [{ \"discussion_id\": \"abc\", \"entity_ref\": \"issues#123\", \"started_by\": \"...\", \"note_count\": 5, \"last_note_at\": \"...\" }],\n \"related\": [{ \"iid\": 789, \"title\": \"...\", \"similarity_score\": 0.85 }],\n \"warnings\": [\"Issue #3800 has no activity for 45 days\"]\n },\n \"meta\": { \"elapsed_ms\": 1200, \"sections_computed\": [\"open_issues\", \"active_mrs\", \"experts\", \"recent_activity\"] }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nBrief {\n /// Free-text topic, entity type, or omit for project-wide brief\n query: Option,\n /// Focus on a file path (who expert mode)\n #[arg(long)]\n path: Option,\n /// Focus on a person (who workload mode)\n #[arg(long)]\n person: Option,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n /// Maximum items per section\n #[arg(long, default_value = \"5\")]\n section_limit: usize,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/brief.rs:\n- test_brief_topic_returns_all_sections: insert test data, search 'auth', assert all section keys present in response\n- test_brief_path_uses_who_expert: brief --path src/auth/, assert experts section populated\n- test_brief_person_uses_who_workload: brief --person user, assert open_issues filtered to user's assignments\n- test_brief_warnings_stale_issue: insert issue with updated_at > 30 days ago, assert warning generated\n- test_brief_token_budget: robot mode output for topic query is under 12000 bytes (~3000 tokens)\n- test_brief_no_embeddings_graceful: related section omitted (not errored) when no embeddings exist\n- test_brief_empty_topic: zero matches returns valid JSON with empty arrays + \"No data found\" summary\n\nGREEN: Implement brief with section assembly, calling existing run_* functions\n\nVERIFY:\n```bash\ncargo test brief:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J brief 'throw time' | jq '.data | keys'\ncargo run --release -- -J brief 'throw time' | wc -c # target <12000\n```\n\n## Acceptance Criteria\n- [ ] lore brief TOPIC returns all sections for free-text topic\n- [ ] lore brief --path PATH returns path-focused briefing with experts\n- [ ] lore brief --person USERNAME returns person-focused briefing\n- [ ] lore brief issues N returns entity-focused briefing\n- [ ] Robot mode output under 12000 bytes (~3000 tokens)\n- [ ] Each section degrades gracefully if its data source is unavailable\n- [ ] summary field is auto-generated one-liner from section counts\n- [ ] warnings detect: stale issues (>30d), unassigned, no due date\n- [ ] Performance: <2s total (acceptable since composing multiple queries)\n- [ ] Command registered in main.rs and robot-docs\n\n## Edge Cases\n- Topic with zero matches: return empty sections + \"No data found for this topic\" summary\n- Path that nobody has touched: experts empty, related may still have results\n- Person not found in DB: exit code 17 with suggestion\n- All sections empty: still return valid JSON with empty arrays\n- Very broad topic (\"the\"): may return too many results — each section respects its limit cap\n- ListFilters/MrListFilters may not derive Default — construct all fields explicitly if needed\n\n## Dependencies\n- Hybrid search (bd-1ksf) for topic relevance ranking\n- lore who (already shipped) for expertise\n- lore related (bd-8con) for semantic connections (BLOCKER — related section is core to the feature)\n- Timeline pipeline (already shipped) for recent activity\n\n## Dependency Context\n- **bd-1ksf (hybrid search)**: Provides `search_hybrid()` which brief uses for topic mode to find relevant entities. Without it, topic mode falls back to FTS-only via `search_fts()`.\n- **bd-8con (related)**: Provides `run_related()` which brief calls to populate the `related` section with semantically similar entities. This is a blocking dependency — the related section is a core differentiator.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/brief.rs\n- src/cli/commands/mod.rs (add pub mod brief; re-export)\n- src/main.rs (register Brief subcommand in Commands enum, add handle_brief fn)\n- Reuse: list.rs, who.rs, timeline.rs, search.rs, show.rs internals","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:47:22.893231Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:31:33.752020Z","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-1n5q","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1n5q","depends_on_id":"bd-1ksf","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1n5q","depends_on_id":"bd-8con","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} +{"id":"bd-1n5q","title":"lore brief: situational awareness for topic/module/person","description":"## Background\nComposable capstone command. An agent says \"I am about to work on auth\" and gets everything in one call: open issues, active MRs, experts, recent activity, unresolved threads, related context. Replaces 5 separate lore calls with 1.\n\n## Input Modes\n1. Topic: `lore brief 'authentication'` — FTS search to find relevant entities, aggregate\n2. Path: `lore brief --path src/auth/` — who expert internals for path expertise\n3. Person: `lore brief --person teernisse` — who workload internals\n4. Entity: `lore brief issues 3864` — single entity focus with cross-references\n\n## Section Assembly Architecture\n\n### Reuse existing run_* functions (ship faster, recommended for v1)\nEach section calls existing CLI command functions and converts their output.\n\nIMPORTANT: All existing run_* functions take `&Config`, NOT `&Connection`. The Config contains the db_path and each function opens its own connection internally.\n\n```rust\n// In src/cli/commands/brief.rs\n\nuse crate::cli::commands::list::{run_list_issues, run_list_mrs, ListFilters, MrListFilters};\nuse crate::cli::commands::who::{run_who, WhoArgs, WhoMode};\nuse crate::core::config::Config;\n\npub async fn run_brief(config: &Config, args: BriefArgs) -> Result {\n let mut sections_computed = Vec::new();\n\n // 1. open_issues: reuse list.rs\n // Signature: pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result\n // Located at src/cli/commands/list.rs:268\n let open_issues = run_list_issues(config, ListFilters {\n state: Some(\"opened\".into()),\n limit: Some(5),\n project: args.project.clone(),\n // ... scope by topic/path/person based on mode\n ..Default::default()\n })?;\n sections_computed.push(\"open_issues\");\n\n // 2. active_mrs: reuse list.rs\n // Signature: pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result\n // Located at src/cli/commands/list.rs:476\n let active_mrs = run_list_mrs(config, MrListFilters {\n state: Some(\"opened\".into()),\n limit: Some(5),\n project: args.project.clone(),\n ..Default::default()\n })?;\n sections_computed.push(\"active_mrs\");\n\n // 3. experts: reuse who.rs\n // Signature: pub fn run_who(config: &Config, args: &WhoArgs) -> Result\n // Located at src/cli/commands/who.rs:276\n let experts = run_who(config, &WhoArgs {\n mode: WhoMode::Expert,\n path: args.path.clone(),\n limit: Some(3),\n ..Default::default()\n })?;\n sections_computed.push(\"experts\");\n\n // 4. recent_activity: reuse timeline internals\n // The timeline pipeline is 5-stage (SEED->HYDRATE->EXPAND->COLLECT->RENDER)\n // Types in src/core/timeline.rs, seed in src/core/timeline_seed.rs\n // ...etc\n}\n```\n\nNOTE: ListFilters and MrListFilters may not implement Default. Check before using `..Default::default()`. If they don't, derive it or construct all fields explicitly.\n\n### Concrete Function References (src/cli/commands/)\n| Module | Function | Signature | Line |\n|--------|----------|-----------|------|\n| list.rs | run_list_issues | `(config: &Config, filters: ListFilters) -> Result` | 268 |\n| list.rs | run_list_mrs | `(config: &Config, filters: MrListFilters) -> Result` | 476 |\n| who.rs | run_who | `(config: &Config, args: &WhoArgs) -> Result` | 276 |\n| search.rs | run_search | `(config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool) -> Result` | 61 |\n\nNOTE: run_search is currently synchronous (pub fn, not pub async fn). If bd-1ksf ships first, it becomes async. Brief should handle both cases — call `.await` if async, direct call if sync.\n\n### Section Details\n| Section | Source | Limit | Fallback |\n|---------|--------|-------|----------|\n| open_issues | list.rs with state=opened | 5 | empty array |\n| active_mrs | list.rs with state=opened | 5 | empty array |\n| experts | who.rs Expert mode | 3 | empty array (no path data) |\n| recent_activity | timeline pipeline | 10 events | empty array |\n| unresolved_threads | SQL: discussions WHERE resolved=false | 5 | empty array |\n| related | search_vector() via bd-8con | 5 | omit section (no embeddings) |\n| warnings | computed from dates/state | all | empty array |\n\n### Warning Generation\n```rust\nfn compute_warnings(issues: &[IssueRow]) -> Vec {\n let now = chrono::Utc::now();\n issues.iter().filter_map(|i| {\n let updated = parse_timestamp(i.updated_at)?;\n let days_stale = (now - updated).num_days();\n if days_stale > 30 {\n Some(format!(\"Issue #{} has no activity for {} days\", i.iid, days_stale))\n } else { None }\n }).chain(\n issues.iter().filter(|i| i.assignees.is_empty())\n .map(|i| format!(\"Issue #{} is unassigned\", i.iid))\n ).collect()\n}\n```\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"mode\": \"topic\",\n \"query\": \"authentication\",\n \"summary\": \"3 open issues, 2 active MRs, top expert: teernisse\",\n \"open_issues\": [{ \"iid\": 123, \"title\": \"...\", \"state\": \"opened\", \"assignees\": [...], \"updated_at\": \"...\", \"labels\": [...] }],\n \"active_mrs\": [{ \"iid\": 456, \"title\": \"...\", \"state\": \"opened\", \"author\": \"...\", \"draft\": false, \"updated_at\": \"...\" }],\n \"experts\": [{ \"username\": \"teernisse\", \"score\": 42, \"last_activity\": \"...\" }],\n \"recent_activity\": [{ \"timestamp\": \"...\", \"event_type\": \"state_change\", \"entity_ref\": \"issues#123\", \"summary\": \"...\", \"actor\": \"...\" }],\n \"unresolved_threads\": [{ \"discussion_id\": \"abc\", \"entity_ref\": \"issues#123\", \"started_by\": \"...\", \"note_count\": 5, \"last_note_at\": \"...\" }],\n \"related\": [{ \"iid\": 789, \"title\": \"...\", \"similarity_score\": 0.85 }],\n \"warnings\": [\"Issue #3800 has no activity for 45 days\"]\n },\n \"meta\": { \"elapsed_ms\": 1200, \"sections_computed\": [\"open_issues\", \"active_mrs\", \"experts\", \"recent_activity\"] }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nBrief {\n /// Free-text topic, entity type, or omit for project-wide brief\n query: Option,\n /// Focus on a file path (who expert mode)\n #[arg(long)]\n path: Option,\n /// Focus on a person (who workload mode)\n #[arg(long)]\n person: Option,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n /// Maximum items per section\n #[arg(long, default_value = \"5\")]\n section_limit: usize,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/brief.rs:\n- test_brief_topic_returns_all_sections: insert test data, search 'auth', assert all section keys present in response\n- test_brief_path_uses_who_expert: brief --path src/auth/, assert experts section populated\n- test_brief_person_uses_who_workload: brief --person user, assert open_issues filtered to user's assignments\n- test_brief_warnings_stale_issue: insert issue with updated_at > 30 days ago, assert warning generated\n- test_brief_token_budget: robot mode output for topic query is under 12000 bytes (~3000 tokens)\n- test_brief_no_embeddings_graceful: related section omitted (not errored) when no embeddings exist\n- test_brief_empty_topic: zero matches returns valid JSON with empty arrays + \"No data found\" summary\n\nGREEN: Implement brief with section assembly, calling existing run_* functions\n\nVERIFY:\n```bash\ncargo test brief:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J brief 'throw time' | jq '.data | keys'\ncargo run --release -- -J brief 'throw time' | wc -c # target <12000\n```\n\n## Acceptance Criteria\n- [ ] lore brief TOPIC returns all sections for free-text topic\n- [ ] lore brief --path PATH returns path-focused briefing with experts\n- [ ] lore brief --person USERNAME returns person-focused briefing\n- [ ] lore brief issues N returns entity-focused briefing\n- [ ] Robot mode output under 12000 bytes (~3000 tokens)\n- [ ] Each section degrades gracefully if its data source is unavailable\n- [ ] summary field is auto-generated one-liner from section counts\n- [ ] warnings detect: stale issues (>30d), unassigned, no due date\n- [ ] Performance: <2s total (acceptable since composing multiple queries)\n- [ ] Command registered in main.rs and robot-docs\n\n## Edge Cases\n- Topic with zero matches: return empty sections + \"No data found for this topic\" summary\n- Path that nobody has touched: experts empty, related may still have results\n- Person not found in DB: exit code 17 with suggestion\n- All sections empty: still return valid JSON with empty arrays\n- Very broad topic (\"the\"): may return too many results — each section respects its limit cap\n- ListFilters/MrListFilters may not derive Default — construct all fields explicitly if needed\n\n## Dependencies\n- Hybrid search (bd-1ksf) for topic relevance ranking\n- lore who (already shipped) for expertise\n- lore related (bd-8con) for semantic connections (BLOCKER — related section is core to the feature)\n- Timeline pipeline (already shipped) for recent activity\n\n## Dependency Context\n- **bd-1ksf (hybrid search)**: Provides `search_hybrid()` which brief uses for topic mode to find relevant entities. Without it, topic mode falls back to FTS-only via `search_fts()`.\n- **bd-8con (related)**: Provides `run_related()` which brief calls to populate the `related` section with semantically similar entities. This is a blocking dependency — the related section is a core differentiator.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/brief.rs\n- src/cli/commands/mod.rs (add pub mod brief; re-export)\n- src/main.rs (register Brief subcommand in Commands enum, add handle_brief fn)\n- Reuse: list.rs, who.rs, timeline.rs, search.rs, show.rs internals","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:47:22.893231Z","created_by":"tayloreernisse","updated_at":"2026-02-19T14:39:48.071273Z","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-1n5q","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1n5q","depends_on_id":"bd-1ksf","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1n5q","depends_on_id":"bd-8con","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-1nf","title":"Register 'lore timeline' command with all flags","description":"## Background\n\nThis bead wires the `lore timeline` command into the CLI — adding the subcommand to the Commands enum, defining all flags, registering in VALID_COMMANDS, and dispatching to the timeline handler. The actual query logic and rendering are in separate beads.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.1 (Command Design).\n\n## Codebase Context\n\n- Commands enum in `src/cli/mod.rs` (line ~86): uses #[derive(Subcommand)] with nested Args structs\n- VALID_COMMANDS in `src/main.rs` (line ~448): &[&str] array for fuzzy command matching\n- Handler dispatch in `src/main.rs` match on Commands:: variants\n- robot-docs manifest in `src/main.rs`: registers commands for `lore robot-docs` output\n- Existing pattern: `Sync(SyncArgs)`, `Search(SearchArgs)`, etc.\n- No timeline module exists yet — this bead creates the CLI entry point only\n\n## Approach\n\n### 1. TimelineArgs struct (`src/cli/mod.rs`):\n\n```rust\n/// Show a chronological timeline of events matching a query\n#[derive(Parser, Debug)]\npub struct TimelineArgs {\n /// Search query (keywords to find in issues, MRs, and discussions)\n pub query: String,\n\n /// Scope to a specific project (fuzzy match)\n #[arg(short = 'p', long)]\n pub project: Option,\n\n /// Only show events after this date (e.g. \"6m\", \"2w\", \"2024-01-01\")\n #[arg(long)]\n pub since: Option,\n\n /// Cross-reference expansion depth (0 = no expansion)\n #[arg(long, default_value = \"1\")]\n pub depth: usize,\n\n /// Also follow 'mentioned' edges during expansion (high fan-out)\n #[arg(long = \"expand-mentions\")]\n pub expand_mentions: bool,\n\n /// Maximum number of events to display\n #[arg(short = 'n', long = \"limit\", default_value = \"100\")]\n pub limit: usize,\n}\n```\n\n### 2. Commands enum variant:\n\n```rust\n/// Show a chronological timeline of events matching a query\n#[command(name = \"timeline\")]\nTimeline(TimelineArgs),\n```\n\n### 3. Handler in `src/main.rs`:\n\n```rust\nCommands::Timeline(args) => {\n // Placeholder: will be filled by bd-2f2 (human) and bd-dty (robot)\n // For now: resolve project, call timeline query, dispatch to renderer\n}\n```\n\n### 4. VALID_COMMANDS: add `\"timeline\"` to the array\n\n### 5. robot-docs: add timeline command description to manifest\n\n## Acceptance Criteria\n\n- [ ] `TimelineArgs` struct with all 6 flags: query, project, since, depth, expand-mentions, limit\n- [ ] Commands::Timeline variant registered in Commands enum\n- [ ] Handler stub in src/main.rs dispatches to timeline logic\n- [ ] `\"timeline\"` added to VALID_COMMANDS array\n- [ ] robot-docs manifest includes timeline command description\n- [ ] `lore timeline --help` shows correct help text\n- [ ] `lore timeline` without query shows error (query is required positional)\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/mod.rs` (TimelineArgs struct + Commands::Timeline variant)\n- `src/main.rs` (handler dispatch + VALID_COMMANDS + robot-docs entry)\n\n## TDD Loop\n\nNo unit tests for CLI wiring. Verify with:\n\n```bash\ncargo check --all-targets\ncargo run -- timeline --help\n```\n\n## Edge Cases\n\n- --since parsing: reuse existing date parsing from ListFilters (src/cli/mod.rs handles \"7d\", \"2w\", \"YYYY-MM-DD\")\n- --depth 0: valid, means no cross-reference expansion\n- --expand-mentions: off by default because mentioned edges have high fan-out\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.422082Z","created_by":"tayloreernisse","updated_at":"2026-02-06T13:49:15.313047Z","closed_at":"2026-02-06T13:49:15.312993Z","close_reason":"Wired lore timeline command: TimelineArgs with 9 flags, Commands::Timeline variant, handle_timeline handler, VALID_COMMANDS entry, robot-docs manifest with temporal_intelligence workflow","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-1nf","depends_on_id":"bd-2f2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1nf","depends_on_id":"bd-dty","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1nf","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-1np","title":"[CP1] GitLab types for issues, discussions, notes","description":"## Background\n\nGitLab types define the Rust structs for deserializing GitLab API responses. These types are the foundation for all ingestion work - issues, discussions, and notes must be correctly typed for serde to parse them.\n\n## Approach\n\nAdd types to `src/gitlab/types.rs` with serde derives:\n\n### GitLabIssue\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabIssue {\n pub id: i64, // GitLab global ID\n pub iid: i64, // Project-scoped issue number\n pub project_id: i64,\n pub title: String,\n pub description: Option,\n pub state: String, // \"opened\" | \"closed\"\n pub created_at: String, // ISO 8601\n pub updated_at: String, // ISO 8601\n pub closed_at: Option,\n pub author: GitLabAuthor,\n pub labels: Vec, // Array of label names (CP1 canonical)\n pub web_url: String,\n}\n```\n\nNOTE: `labels_details` intentionally NOT modeled - varies across GitLab versions.\n\n### GitLabAuthor\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabAuthor {\n pub id: i64,\n pub username: String,\n pub name: String,\n}\n```\n\n### GitLabDiscussion\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabDiscussion {\n pub id: String, // String ID like \"6a9c1750b37d...\"\n pub individual_note: bool, // true = standalone comment\n pub notes: Vec,\n}\n```\n\n### GitLabNote\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabNote {\n pub id: i64,\n #[serde(rename = \"type\")]\n pub note_type: Option, // \"DiscussionNote\" | \"DiffNote\" | null\n pub body: String,\n pub author: GitLabAuthor,\n pub created_at: String, // ISO 8601\n pub updated_at: String, // ISO 8601\n pub system: bool, // true for system-generated notes\n #[serde(default)]\n pub resolvable: bool,\n #[serde(default)]\n pub resolved: bool,\n pub resolved_by: Option,\n pub resolved_at: Option,\n pub position: Option,\n}\n```\n\n### GitLabNotePosition\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabNotePosition {\n pub old_path: Option,\n pub new_path: Option,\n pub old_line: Option,\n pub new_line: Option,\n}\n```\n\n## Acceptance Criteria\n\n- [ ] GitLabIssue deserializes from API response JSON\n- [ ] GitLabAuthor embedded correctly in issue and note\n- [ ] GitLabDiscussion with notes array deserializes\n- [ ] GitLabNote handles null note_type (use Option)\n- [ ] GitLabNote uses #[serde(rename = \"type\")] for reserved keyword\n- [ ] resolvable/resolved default to false via #[serde(default)]\n- [ ] All timestamp fields are String (ISO 8601 parsed elsewhere)\n\n## Files\n\n- src/gitlab/types.rs (edit - add types)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/gitlab_types_tests.rs\n#[test] fn deserializes_gitlab_issue_from_json()\n#[test] fn deserializes_gitlab_discussion_from_json()\n#[test] fn handles_null_note_type()\n#[test] fn handles_missing_resolvable_field()\n#[test] fn deserializes_labels_as_string_array()\n```\n\nGREEN: Add type definitions with serde attributes\n\nVERIFY: `cargo test gitlab_types`\n\n## Edge Cases\n\n- note_type can be null, \"DiscussionNote\", or \"DiffNote\"\n- labels array can be empty\n- description can be null\n- resolved_by/resolved_at can be null\n- position is only present for DiffNotes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.150472Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:17:08.842965Z","closed_at":"2026-01-25T22:17:08.842895Z","close_reason":"Implemented GitLabAuthor, GitLabIssue, GitLabDiscussion, GitLabNote, GitLabNotePosition types with 10 passing tests","compaction_level":0,"original_size":0} {"id":"bd-1nsl","title":"Epic: Surgical Per-IID Sync","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-17T19:11:34.020453Z","created_by":"tayloreernisse","updated_at":"2026-02-19T13:47:02.942871Z","closed_at":"2026-02-19T13:47:02.942827Z","close_reason":"All child beads completed: bd-wcja (SyncResult fields), bd-1elx (SurgicalTarget types), bd-hs6j (TOCTOU guard), bd-3sez (preflight_fetch), bd-arka (ingest_issue_by_iid), bd-tiux (ingest_mr_by_iid), bd-1lja (SyncOptions extensions), bd-159p (dirty_tracker), bd-1sc6 (SyncRunRecorder), bd-kanh (enrichment helpers), bd-1i4i (orchestrator), bd-3bec (dispatch wiring). 886 tests pass.","compaction_level":0,"original_size":0,"labels":["surgical-sync"]} @@ -126,7 +126,7 @@ {"id":"bd-2as","title":"[CP1] Epic: Issue Ingestion","description":"Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.\n\nSuccess Criteria:\n- gi ingest --type=issues fetches all issues (count matches GitLab UI)\n- Labels extracted from issue payloads\n- Issue discussions fetched per-issue\n- Cursor-based sync is resumable\n- Sync tracking records all runs\n- Single-flight lock prevents concurrent runs\n\nReference: docs/prd/checkpoint-1.md","status":"tombstone","priority":1,"issue_type":"task","created_at":"2026-01-25T15:18:44.062057Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.155746Z","closed_at":"2026-01-25T15:21:35.155746Z","deleted_at":"2026-01-25T15:21:35.155744Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2b28","title":"NOTE-0C: Sweep safety guard for partial fetch protection","description":"## Background\nThe sweep pattern (delete notes where last_seen_at < run_seen_at) is correct only when a discussion's notes were fully fetched. If a page fails mid-fetch, the current logic would incorrectly delete valid notes that weren't seen during the incomplete fetch. Especially dangerous for long threads spanning multiple API pages.\n\n## Approach\nAdd a fetch_complete: bool parameter to discussion ingestion functions. Only run sweep when fetch completed successfully:\n\nif fetch_complete {\n sweep_stale_issue_notes(&tx, local_discussion_id, last_seen_at)?;\n} else {\n tracing::warn!(discussion_id = local_discussion_id, \"Skipping stale note sweep due to partial/incomplete fetch\");\n}\n\nDetermining fetch_complete: Look at the existing pagination_error pattern in src/ingestion/discussions.rs lines 148-154. When pagination_error is None (all pages fetched successfully), fetch_complete = true. When pagination_error is Some (network error, rate limit, interruption), fetch_complete = false. The MR path has a similar pattern in src/ingestion/mr_discussions.rs — search for where sweep_stale_discussions (line 539) and sweep_stale_notes (line 551) are called to find the equivalent guard.\n\nThe fetch_complete flag should be threaded from the outer discussion-fetch loop into the per-discussion upsert transaction, NOT as a parameter on sweep itself (sweep always sweeps — the caller decides whether to call it).\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (guard sweep call with fetch_complete, lines 132-146)\n- MODIFY: src/ingestion/mr_discussions.rs (guard sweep call, near line 551 call site)\n\n## TDD Anchor\nRED: test_partial_fetch_does_not_sweep_notes — 5 notes in DB, partial fetch returns 2, assert all 5 still exist.\nGREEN: Add fetch_complete guard around sweep call.\nVERIFY: cargo test partial_fetch_does_not_sweep -- --nocapture\nTests: test_complete_fetch_runs_sweep_normally, test_partial_fetch_then_complete_fetch_cleans_up\n\n## Acceptance Criteria\n- [ ] Sweep only runs when fetch_complete = true\n- [ ] Partial fetch logs a warning (tracing::warn!) but preserves all notes\n- [ ] Second complete fetch correctly sweeps notes deleted on GitLab\n- [ ] Both issue and MR discussion paths support fetch_complete\n- [ ] All 3 tests pass\n\n## Dependency Context\n- Depends on NOTE-0A (bd-3bpk): modifies the sweep call site from NOTE-0A. The sweep functions must exist before this guard can wrap them.\n\n## Edge Cases\n- Rate limit mid-page: pagination_error triggers partial fetch — sweep must be skipped\n- Discussion with 1 page of notes: always fully fetched if no error, sweep runs normally\n- Empty discussion (0 notes returned): still counts as complete fetch — sweep is a no-op anyway","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:44.290790Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.172004Z","closed_at":"2026-02-12T18:13:15.171952Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]} {"id":"bd-2bu","title":"[CP1] GitLab types for issues, discussions, notes","description":"Add Rust types to src/gitlab/types.rs for GitLab API responses.\n\n## Types to Add\n\n### GitLabIssue\n- id: i64 (GitLab global ID)\n- iid: i64 (project-scoped issue number)\n- project_id: i64\n- title: String\n- description: Option\n- state: String (\"opened\" | \"closed\")\n- created_at, updated_at: String (ISO 8601)\n- closed_at: Option\n- author: GitLabAuthor\n- labels: Vec (array of label names - CP1 canonical)\n- web_url: String\nNOTE: labels_details intentionally NOT modeled - varies across GitLab versions\n\n### GitLabAuthor\n- id: i64\n- username: String\n- name: String\n\n### GitLabDiscussion\n- id: String (like \"6a9c1750b37d...\")\n- individual_note: bool\n- notes: Vec\n\n### GitLabNote\n- id: i64\n- note_type: Option (\"DiscussionNote\" | \"DiffNote\" | null)\n- body: String\n- author: GitLabAuthor\n- created_at, updated_at: String (ISO 8601)\n- system: bool\n- resolvable: bool (default false)\n- resolved: bool (default false)\n- resolved_by: Option\n- resolved_at: Option\n- position: Option\n\n### GitLabNotePosition\n- old_path, new_path: Option\n- old_line, new_line: Option\n\nFiles: src/gitlab/types.rs\nTests: Test deserialization with fixtures\nDone when: Types compile and deserialize sample API responses correctly","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:46.922805Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.710057Z","closed_at":"2026-01-25T17:02:01.710057Z","deleted_at":"2026-01-25T17:02:01.710051Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-2cbw","title":"Split who.rs into who/ module (queries + scoring + formatting)","description":"who.rs is 3742 lines — the largest file in the project. Split into src/cli/commands/who/mod.rs (re-exports, CLI arg handling), who/queries.rs (SQL queries, data retrieval), who/scoring.rs (half_life_decay, normalize_query_path, scoring aggregation), who/format.rs (human-readable + robot-mode output formatting). Keep public API identical via re-exports.","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-12T21:24:22.040110Z","created_by":"tayloreernisse","updated_at":"2026-02-19T14:28:03.027759Z","close_reason":"who.rs split completed (per user)","compaction_level":0,"original_size":0,"labels":["CLI"]} +{"id":"bd-2cbw","title":"Split who.rs into who/ module (queries + scoring + formatting)","description":"who.rs is 3742 lines — the largest file in the project. Split into src/cli/commands/who/mod.rs (re-exports, CLI arg handling), who/queries.rs (SQL queries, data retrieval), who/scoring.rs (half_life_decay, normalize_query_path, scoring aggregation), who/format.rs (human-readable + robot-mode output formatting). Keep public API identical via re-exports.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T21:24:22.040110Z","created_by":"tayloreernisse","updated_at":"2026-02-19T14:44:24.582123Z","closed_at":"2026-02-19T14:44:24.582076Z","close_reason":"Split who.rs (2447 lines) into who/ module: scoring.rs, queries.rs, format.rs + slim module root","compaction_level":0,"original_size":0,"labels":["CLI"]} {"id":"bd-2cu","title":"[CP1] Discussion ingestion module","description":"Fetch and store discussions/notes for each issue.\n\n## Module\nsrc/ingestion/discussions.rs\n\n## Key Structs\n\n### IngestDiscussionsResult\n- discussions_fetched: usize\n- discussions_upserted: usize\n- notes_upserted: usize\n- system_notes_count: usize\n\n## Main Function\npub async fn ingest_issue_discussions(\n conn, client, config,\n project_id, gitlab_project_id,\n issue_iid, local_issue_id, issue_updated_at\n) -> Result\n\n## Logic\n1. Paginate through all discussions for given issue\n2. For each discussion:\n - Begin transaction\n - Store raw payload (compressed)\n - Transform and upsert discussion record with correct issue FK\n - Get local discussion ID\n - Transform notes from discussion\n - For each note:\n - Store raw payload\n - Upsert note with discussion_id FK\n - Count system notes\n - Commit transaction\n3. After all discussions synced: mark_discussions_synced(conn, local_issue_id, issue_updated_at)\n - UPDATE issues SET discussions_synced_for_updated_at = ? WHERE id = ?\n\n## Invariant\nA rerun MUST NOT refetch discussions for issues whose updated_at has not advanced, even with cursor rewind. The discussions_synced_for_updated_at watermark ensures this.\n\n## Helper Functions\n- upsert_discussion(conn, discussion, payload_id)\n- get_local_discussion_id(conn, project_id, gitlab_id) -> i64\n- upsert_note(conn, discussion_id, note, payload_id)\n- mark_discussions_synced(conn, issue_id, issue_updated_at)\n\nFiles: src/ingestion/discussions.rs\nTests: tests/discussion_watermark_tests.rs\nDone when: Discussions and notes populated with correct FKs, watermark prevents redundant refetch","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:36.703237Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.827880Z","closed_at":"2026-01-25T17:02:01.827880Z","deleted_at":"2026-01-25T17:02:01.827876Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2dk","title":"Implement project resolution for --project filter","description":"## Background\nThe --project filter on search (and other commands) accepts a string that must be resolved to a project_id. Users may type the full path, a partial path, or just the project name. The resolution logic provides cascading match with helpful error messages when ambiguous. This improves ergonomics for multi-project installations.\n\n## Approach\nImplement project resolution function (location TBD — likely `src/core/project.rs` or inline in search filters):\n\n```rust\n/// Resolve a project string to a project_id using cascading match:\n/// 1. Exact match on path_with_namespace\n/// 2. Case-insensitive exact match\n/// 3. Suffix match (only if unambiguous)\n/// 4. Error with available projects list\npub fn resolve_project(conn: &Connection, project_str: &str) -> Result\n```\n\n**SQL queries:**\n```sql\n-- Step 1: exact match\nSELECT id FROM projects WHERE path_with_namespace = ?\n\n-- Step 2: case-insensitive\nSELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?)\n\n-- Step 3: suffix match\nSELECT id, path_with_namespace FROM projects\nWHERE path_with_namespace LIKE '%/' || ?\n OR path_with_namespace = ?\n\n-- Step 4: list all for error message\nSELECT path_with_namespace FROM projects ORDER BY path_with_namespace\n```\n\n**Error format:**\n```\nError: Project 'auth-service' not found.\n\nAvailable projects:\n backend/auth-service\n frontend/auth-service-ui\n infra/auth-proxy\n\nHint: Use the full path, e.g., --project=backend/auth-service\n```\n\nUses `LoreError::Ambiguous` variant for multiple suffix matches.\n\n## Acceptance Criteria\n- [ ] Exact match: \"group/project\" resolves correctly\n- [ ] Case-insensitive: \"Group/Project\" resolves to \"group/project\"\n- [ ] Suffix match: \"project-name\" resolves when only one \"*/project-name\" exists\n- [ ] Ambiguous suffix: error lists matching projects with hint\n- [ ] No match: error lists all available projects with hint\n- [ ] Empty projects table: clear error message\n- [ ] `cargo test project_resolution` passes\n\n## Files\n- `src/core/project.rs` — new file (or add to existing module)\n- `src/core/mod.rs` — add `pub mod project;`\n\n## TDD Loop\nRED: Tests:\n- `test_exact_match` — full path resolves\n- `test_case_insensitive` — mixed case resolves\n- `test_suffix_unambiguous` — short name resolves when unique\n- `test_suffix_ambiguous` — error with list when multiple match\n- `test_no_match` — error with available projects\nGREEN: Implement resolve_project\nVERIFY: `cargo test project_resolution`\n\n## Edge Cases\n- Project path containing special LIKE characters (%, _): unlikely but escape for safety\n- Single project in DB: suffix always unambiguous\n- Project path with multiple slashes: \"org/group/project\" — suffix match on \"project\" works","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:26:13.076571Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:39:17.197735Z","closed_at":"2026-01-30T17:39:17.197552Z","close_reason":"Implemented resolve_project() with cascading match (exact, CI, suffix) + 6 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dk","depends_on_id":"bd-3q2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"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"}]} diff --git a/.beads/last-touched b/.beads/last-touched index 220e7cb..0c1bd4d 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -bd-9lbr +bd-2cbw diff --git a/src/cli/commands/who.rs b/src/cli/commands/who.rs index 0daa860..605b54d 100644 --- a/src/cli/commands/who.rs +++ b/src/cli/commands/who.rs @@ -1,21 +1,41 @@ -use crate::cli::render::{self, Icons, Theme}; +pub mod format; +pub mod queries; +pub mod scoring; + use rusqlite::Connection; -use serde::Serialize; -use std::collections::{HashMap, HashSet}; use crate::Config; use crate::cli::WhoArgs; -use crate::cli::robot::RobotMeta; +#[cfg(test)] use crate::core::config::ScoringConfig; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; -use crate::core::path_resolver::{PathQuery, build_path_query, normalize_repo_path}; +use crate::core::path_resolver::normalize_repo_path; #[cfg(test)] -use crate::core::path_resolver::{SuffixResult, escape_like, suffix_probe}; +use crate::core::path_resolver::{SuffixResult, build_path_query, escape_like, suffix_probe}; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::{ms_to_iso, now_ms, parse_since, parse_since_from}; +// Test-visible re-imports from submodules (needed by who_tests.rs via `use super::*`) +#[cfg(test)] +use queries::{build_expert_sql_v2, format_overlap_role, normalize_review_prefix}; + +// ─── Re-exports ───────────────────────────────────────────────────────────── +// +// Shared result types live in core::who_types. Re-exported here for +// backwards-compatible use within the CLI. + +pub use crate::core::who_types::{ + ActiveDiscussion, ActiveResult, Expert, ExpertMrDetail, ExpertResult, OverlapResult, + OverlapUser, ReviewCategory, ReviewsResult, ScoreComponents, WhoResult, WorkloadDiscussion, + WorkloadIssue, WorkloadMr, WorkloadResult, +}; + +pub use format::{print_who_human, print_who_json}; +pub use queries::{query_active, query_expert, query_overlap, query_reviews, query_workload}; +pub use scoring::half_life_decay; + // ─── Mode Discrimination ──────────────────────────────────────────────────── /// Determines which query mode to run based on args. @@ -78,16 +98,7 @@ fn resolve_mode<'a>(args: &'a WhoArgs) -> Result> { )) } -// ─── Result Types ──────────────────────────────────────────────────────────── -// -// Shared result types live in core::who_types. Re-exported here for -// backwards-compatible use within the CLI. - -pub use crate::core::who_types::{ - ActiveDiscussion, ActiveResult, Expert, ExpertMrDetail, ExpertResult, OverlapResult, - OverlapUser, ReviewCategory, ReviewsResult, ScoreComponents, WhoResult, WorkloadDiscussion, - WorkloadIssue, WorkloadMr, WorkloadResult, -}; +// ─── Result Types ─────────────────────────────────────────────────────────── /// Top-level run result: carries resolved inputs + the mode-specific result. pub struct WhoRun { @@ -107,10 +118,7 @@ pub struct WhoResolvedInput { pub limit: u16, } -/// Maximum MR references to retain per user in output (shared across modes). -const MAX_MR_REFS_PER_USER: usize = 50; - -// ─── Entry Point ───────────────────────────────────────────────────────────── +// ─── Entry Point ──────────────────────────────────────────────────────────── /// Main entry point. Resolves mode + resolved inputs once, then dispatches. pub fn run_who(config: &Config, args: &WhoArgs) -> Result { @@ -276,7 +284,7 @@ fn validate_mode_flags(mode: &WhoMode<'_>, args: &WhoArgs) -> Result<()> { Ok(()) } -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ─── Helpers ──────────────────────────────────────────────────────────────── /// Look up the project path for a resolved project ID. fn lookup_project_path(conn: &Connection, project_id: i64) -> Result { @@ -318,2129 +326,7 @@ fn resolve_since_required(input: &str) -> Result { }) } -// ─── Path Query Construction ───────────────────────────────────────────────── - -// ─── Scoring Helpers ───────────────────────────────────────────────────────── - -/// Exponential half-life decay: `2^(-days / half_life)`. -/// -/// Returns a value in `[0.0, 1.0]` representing how much of an original signal -/// is retained after `elapsed_ms` milliseconds, given a `half_life_days` period. -/// At `elapsed=0` the signal is fully retained (1.0); at `elapsed=half_life` -/// exactly half remains (0.5); the signal halves again for each additional -/// half-life period. -/// -/// Returns `0.0` when `half_life_days` is zero (prevents division by zero). -/// Negative elapsed values are clamped to zero (future events retain full weight). -pub fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 { - let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0); - let hl = f64::from(half_life_days); - if hl <= 0.0 { - return 0.0; - } - 2.0_f64.powf(-days / hl) -} - -// ─── Query: Expert Mode ───────────────────────────────────────────────────── - -#[allow(clippy::too_many_arguments)] -pub fn query_expert( - conn: &Connection, - path: &str, - project_id: Option, - since_ms: i64, - as_of_ms: i64, - limit: usize, - scoring: &ScoringConfig, - detail: bool, - explain_score: bool, - include_bots: bool, -) -> Result { - let pq = build_path_query(conn, path, project_id)?; - - let sql = build_expert_sql_v2(pq.is_prefix); - let mut stmt = conn.prepare_cached(&sql)?; - - // Params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms, - // ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars - let rows = stmt.query_map( - rusqlite::params![ - pq.value, - since_ms, - project_id, - as_of_ms, - scoring.closed_mr_multiplier, - scoring.reviewer_min_note_chars, - ], - |row| { - Ok(SignalRow { - username: row.get(0)?, - signal: row.get(1)?, - mr_id: row.get(2)?, - qty: row.get(3)?, - ts: row.get(4)?, - state_mult: row.get(5)?, - }) - }, - )?; - - // Per-user accumulator keyed by username. - let mut accum: HashMap = HashMap::new(); - - for row_result in rows { - let r = row_result?; - let entry = accum - .entry(r.username.clone()) - .or_insert_with(|| UserAccum { - contributions: Vec::new(), - last_seen_ms: 0, - mr_ids_author: HashSet::new(), - mr_ids_reviewer: HashSet::new(), - note_count: 0, - }); - - if r.ts > entry.last_seen_ms { - entry.last_seen_ms = r.ts; - } - - match r.signal.as_str() { - "diffnote_author" | "file_author" => { - entry.mr_ids_author.insert(r.mr_id); - } - "file_reviewer_participated" | "file_reviewer_assigned" => { - entry.mr_ids_reviewer.insert(r.mr_id); - } - "note_group" => { - entry.note_count += r.qty as u32; - // DiffNote reviewers are also reviewer activity. - entry.mr_ids_reviewer.insert(r.mr_id); - } - _ => {} - } - - entry.contributions.push(Contribution { - signal: r.signal, - mr_id: r.mr_id, - qty: r.qty, - ts: r.ts, - state_mult: r.state_mult, - }); - } - - // Bot filtering: exclude configured bot usernames (case-insensitive). - if !include_bots && !scoring.excluded_usernames.is_empty() { - let excluded: HashSet = scoring - .excluded_usernames - .iter() - .map(|u| u.to_lowercase()) - .collect(); - accum.retain(|username, _| !excluded.contains(&username.to_lowercase())); - } - - // Compute decayed scores with deterministic ordering. - let mut scored: Vec = accum - .into_iter() - .map(|(username, mut ua)| { - // Sort contributions by mr_id ASC for deterministic f64 summation. - ua.contributions.sort_by_key(|c| c.mr_id); - - let mut comp_author = 0.0_f64; - let mut comp_reviewer_participated = 0.0_f64; - let mut comp_reviewer_assigned = 0.0_f64; - let mut comp_notes = 0.0_f64; - - for c in &ua.contributions { - let elapsed = as_of_ms - c.ts; - match c.signal.as_str() { - "diffnote_author" | "file_author" => { - let decay = half_life_decay(elapsed, scoring.author_half_life_days); - comp_author += scoring.author_weight as f64 * decay * c.state_mult; - } - "file_reviewer_participated" => { - let decay = half_life_decay(elapsed, scoring.reviewer_half_life_days); - comp_reviewer_participated += - scoring.reviewer_weight as f64 * decay * c.state_mult; - } - "file_reviewer_assigned" => { - let decay = - half_life_decay(elapsed, scoring.reviewer_assignment_half_life_days); - comp_reviewer_assigned += - scoring.reviewer_assignment_weight as f64 * decay * c.state_mult; - } - "note_group" => { - let decay = half_life_decay(elapsed, scoring.note_half_life_days); - // Diminishing returns: log2(1 + count) per MR. - let note_value = (1.0 + c.qty as f64).log2(); - comp_notes += scoring.note_bonus as f64 * note_value * decay * c.state_mult; - } - _ => {} - } - } - - let raw_score = - comp_author + comp_reviewer_participated + comp_reviewer_assigned + comp_notes; - ScoredUser { - username, - raw_score, - components: ScoreComponents { - author: comp_author, - reviewer_participated: comp_reviewer_participated, - reviewer_assigned: comp_reviewer_assigned, - notes: comp_notes, - }, - accum: ua, - } - }) - .collect(); - - // Sort: raw_score DESC, last_seen DESC, username ASC (deterministic tiebreaker). - scored.sort_by(|a, b| { - b.raw_score - .partial_cmp(&a.raw_score) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| b.accum.last_seen_ms.cmp(&a.accum.last_seen_ms)) - .then_with(|| a.username.cmp(&b.username)) - }); - - let truncated = scored.len() > limit; - scored.truncate(limit); - - // Build Expert structs with MR refs. - let mut experts: Vec = scored - .into_iter() - .map(|su| { - let mut mr_refs = build_mr_refs_for_user(conn, &su.accum); - mr_refs.sort(); - let mr_refs_total = mr_refs.len() as u32; - let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER; - if mr_refs_truncated { - mr_refs.truncate(MAX_MR_REFS_PER_USER); - } - Expert { - username: su.username, - score: su.raw_score.round() as i64, - score_raw: if explain_score { - Some(su.raw_score) - } else { - None - }, - components: if explain_score { - Some(su.components) - } else { - None - }, - review_mr_count: su.accum.mr_ids_reviewer.len() as u32, - review_note_count: su.accum.note_count, - author_mr_count: su.accum.mr_ids_author.len() as u32, - last_seen_ms: su.accum.last_seen_ms, - mr_refs, - mr_refs_total, - mr_refs_truncated, - details: None, - } - }) - .collect(); - - // Populate per-MR detail when --detail is requested - if detail && !experts.is_empty() { - let details_map = query_expert_details(conn, &pq, &experts, since_ms, project_id)?; - for expert in &mut experts { - expert.details = details_map.get(&expert.username).cloned(); - } - } - - Ok(ExpertResult { - path_query: if pq.is_prefix { - // Use raw input (unescaped) for display — pq.value has LIKE escaping. - path.trim_end_matches('/').to_string() - } else { - // For exact matches (including suffix-resolved), show the resolved path. - pq.value.clone() - }, - path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(), - experts, - truncated, - }) -} - -/// Raw signal row from the v2 CTE query. -struct SignalRow { - username: String, - signal: String, - mr_id: i64, - qty: i64, - ts: i64, - state_mult: f64, -} - -/// Per-user signal accumulator used during Rust-side scoring. -struct UserAccum { - contributions: Vec, - last_seen_ms: i64, - mr_ids_author: HashSet, - mr_ids_reviewer: HashSet, - note_count: u32, -} - -/// A single contribution to a user's score (one signal row). -struct Contribution { - signal: String, - mr_id: i64, - qty: i64, - ts: i64, - state_mult: f64, -} - -/// Intermediate scored user before building Expert structs. -struct ScoredUser { - username: String, - raw_score: f64, - components: ScoreComponents, - accum: UserAccum, -} - -/// Build MR refs (e.g. "group/project!123") for a user from their accumulated MR IDs. -fn build_mr_refs_for_user(conn: &Connection, ua: &UserAccum) -> Vec { - let all_mr_ids: HashSet = ua - .mr_ids_author - .iter() - .chain(ua.mr_ids_reviewer.iter()) - .copied() - .chain(ua.contributions.iter().map(|c| c.mr_id)) - .collect(); - - if all_mr_ids.is_empty() { - return Vec::new(); - } - - let placeholders: Vec = (1..=all_mr_ids.len()).map(|i| format!("?{i}")).collect(); - let sql = format!( - "SELECT p.path_with_namespace || '!' || CAST(m.iid AS TEXT) - FROM merge_requests m - JOIN projects p ON m.project_id = p.id - WHERE m.id IN ({})", - placeholders.join(",") - ); - - let mut stmt = match conn.prepare(&sql) { - Ok(s) => s, - Err(_) => return Vec::new(), - }; - - let mut mr_ids_vec: Vec = all_mr_ids.into_iter().collect(); - mr_ids_vec.sort_unstable(); - let params: Vec<&dyn rusqlite::types::ToSql> = mr_ids_vec - .iter() - .map(|id| id as &dyn rusqlite::types::ToSql) - .collect(); - - stmt.query_map(&*params, |row| row.get::<_, String>(0)) - .map(|rows| rows.filter_map(|r| r.ok()).collect()) - .unwrap_or_default() -} - -/// Build the CTE-based expert SQL for time-decay scoring (v2). -/// -/// Returns raw signal rows `(username, signal, mr_id, qty, ts, state_mult)` that -/// Rust aggregates with per-signal decay and `log2(1+count)` for note groups. -/// -/// Parameters: `?1` = path, `?2` = since_ms, `?3` = project_id (nullable), -/// `?4` = as_of_ms, `?5` = closed_mr_multiplier, `?6` = reviewer_min_note_chars -fn build_expert_sql_v2(is_prefix: bool) -> String { - let path_op = if is_prefix { - "LIKE ?1 ESCAPE '\\'" - } else { - "= ?1" - }; - // INDEXED BY hints for each branch: - // - new_path branch: idx_notes_diffnote_path_created (existing) - // - old_path branch: idx_notes_old_path_author (migration 026) - format!( - " -WITH matched_notes_raw AS ( - -- Branch 1: match on position_new_path - SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id - FROM notes n INDEXED BY idx_notes_diffnote_path_created - WHERE n.note_type = 'DiffNote' - AND n.is_system = 0 - AND n.author_username IS NOT NULL - AND n.created_at >= ?2 - AND n.created_at < ?4 - AND (?3 IS NULL OR n.project_id = ?3) - AND n.position_new_path {path_op} - UNION ALL - -- Branch 2: match on position_old_path - SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id - FROM notes n INDEXED BY idx_notes_old_path_author - WHERE n.note_type = 'DiffNote' - AND n.is_system = 0 - AND n.author_username IS NOT NULL - AND n.created_at >= ?2 - AND n.created_at < ?4 - AND (?3 IS NULL OR n.project_id = ?3) - AND n.position_old_path IS NOT NULL - AND n.position_old_path {path_op} -), -matched_notes AS ( - -- Dedup: prevent double-counting when old_path = new_path (no rename) - SELECT DISTINCT id, discussion_id, author_username, created_at, project_id - FROM matched_notes_raw -), -matched_file_changes_raw AS ( - -- Branch 1: match on new_path - SELECT fc.merge_request_id, fc.project_id - FROM mr_file_changes fc INDEXED BY idx_mfc_new_path_project_mr - WHERE (?3 IS NULL OR fc.project_id = ?3) - AND fc.new_path {path_op} - UNION ALL - -- Branch 2: match on old_path - SELECT fc.merge_request_id, fc.project_id - FROM mr_file_changes fc INDEXED BY idx_mfc_old_path_project_mr - WHERE (?3 IS NULL OR fc.project_id = ?3) - AND fc.old_path IS NOT NULL - AND fc.old_path {path_op} -), -matched_file_changes AS ( - -- Dedup: prevent double-counting when old_path = new_path (no rename) - SELECT DISTINCT merge_request_id, project_id - FROM matched_file_changes_raw -), -mr_activity AS ( - -- Centralized state-aware timestamps and state multiplier. - -- Scoped to MRs matched by file changes to avoid materializing the full MR table. - SELECT DISTINCT - m.id AS mr_id, - m.author_username, - m.state, - CASE - WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at) - WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at) - ELSE COALESCE(m.updated_at, m.created_at) - END AS activity_ts, - CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult - FROM merge_requests m - JOIN matched_file_changes mfc ON mfc.merge_request_id = m.id - WHERE m.state IN ('opened','merged','closed') -), -reviewer_participation AS ( - -- Precompute which (mr_id, username) pairs have substantive DiffNote participation. - SELECT DISTINCT d.merge_request_id AS mr_id, mn.author_username AS username - FROM matched_notes mn - JOIN discussions d ON mn.discussion_id = d.id - JOIN notes n_body ON mn.id = n_body.id - WHERE d.merge_request_id IS NOT NULL - AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6 -), -raw AS ( - -- Signal 1: DiffNote reviewer (individual notes for note_cnt) - SELECT mn.author_username AS username, 'diffnote_reviewer' AS signal, - m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at, - CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult - FROM matched_notes mn - JOIN discussions d ON mn.discussion_id = d.id - JOIN merge_requests m ON d.merge_request_id = m.id - WHERE (m.author_username IS NULL OR mn.author_username != m.author_username) - AND m.state IN ('opened','merged','closed') - - UNION ALL - - -- Signal 2: DiffNote MR author - SELECT m.author_username AS username, 'diffnote_author' AS signal, - m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at, - CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult - FROM merge_requests m - JOIN discussions d ON d.merge_request_id = m.id - JOIN matched_notes mn ON mn.discussion_id = d.id - WHERE m.author_username IS NOT NULL - AND m.state IN ('opened','merged','closed') - GROUP BY m.author_username, m.id - - UNION ALL - - -- Signal 3: MR author via file changes (uses mr_activity CTE) - SELECT a.author_username AS username, 'file_author' AS signal, - a.mr_id, NULL AS note_id, - a.activity_ts AS seen_at, a.state_mult - FROM mr_activity a - WHERE a.author_username IS NOT NULL - AND a.activity_ts >= ?2 - AND a.activity_ts < ?4 - - UNION ALL - - -- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path) - SELECT r.username AS username, 'file_reviewer_participated' AS signal, - a.mr_id, NULL AS note_id, - a.activity_ts AS seen_at, a.state_mult - FROM mr_activity a - JOIN mr_reviewers r ON r.merge_request_id = a.mr_id - JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username - WHERE r.username IS NOT NULL - AND (a.author_username IS NULL OR r.username != a.author_username) - AND a.activity_ts >= ?2 - AND a.activity_ts < ?4 - - UNION ALL - - -- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path) - SELECT r.username AS username, 'file_reviewer_assigned' AS signal, - a.mr_id, NULL AS note_id, - a.activity_ts AS seen_at, a.state_mult - FROM mr_activity a - JOIN mr_reviewers r ON r.merge_request_id = a.mr_id - LEFT JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username - WHERE rp.username IS NULL - AND r.username IS NOT NULL - AND (a.author_username IS NULL OR r.username != a.author_username) - AND a.activity_ts >= ?2 - AND a.activity_ts < ?4 -), -aggregated AS ( - -- MR-level signals: 1 row per (username, signal_class, mr_id) with MAX(ts) - SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult - FROM raw WHERE signal != 'diffnote_reviewer' - GROUP BY username, signal, mr_id - UNION ALL - -- Note signals: 1 row per (username, mr_id) with note_count and max_ts - SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts, - MAX(state_mult) AS state_mult - FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL - GROUP BY username, mr_id -) -SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated WHERE username IS NOT NULL - " - ) -} - -/// Query per-MR detail for a set of experts. Returns a map of username -> Vec. -fn query_expert_details( - conn: &Connection, - pq: &PathQuery, - experts: &[Expert], - since_ms: i64, - project_id: Option, -) -> Result>> { - let path_op = if pq.is_prefix { - "LIKE ?1 ESCAPE '\\'" - } else { - "= ?1" - }; - - // Build IN clause for usernames - let placeholders: Vec = experts - .iter() - .enumerate() - .map(|(i, _)| format!("?{}", i + 4)) - .collect(); - let in_clause = placeholders.join(","); - - let sql = format!( - " - WITH signals AS ( - -- 1. DiffNote reviewer (matches both new_path and old_path for renamed files) - SELECT - n.author_username AS username, - 'reviewer' AS role, - m.id AS mr_id, - (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref, - m.title AS title, - COUNT(*) AS note_count, - MAX(n.created_at) AS last_activity - FROM notes n - JOIN discussions d ON n.discussion_id = d.id - JOIN merge_requests m ON d.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - WHERE n.note_type = 'DiffNote' - AND n.is_system = 0 - AND n.author_username IS NOT NULL - AND (m.author_username IS NULL OR n.author_username != m.author_username) - AND m.state IN ('opened','merged','closed') - AND (n.position_new_path {path_op} - OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op})) - AND n.created_at >= ?2 - AND (?3 IS NULL OR n.project_id = ?3) - AND n.author_username IN ({in_clause}) - GROUP BY n.author_username, m.id - - UNION ALL - - -- 2. DiffNote MR author (matches both new_path and old_path for renamed files) - SELECT - m.author_username AS username, - 'author' AS role, - m.id AS mr_id, - (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref, - m.title AS title, - 0 AS note_count, - MAX(n.created_at) AS last_activity - FROM merge_requests m - JOIN discussions d ON d.merge_request_id = m.id - JOIN notes n ON n.discussion_id = d.id - JOIN projects p ON m.project_id = p.id - WHERE n.note_type = 'DiffNote' - AND n.is_system = 0 - AND m.author_username IS NOT NULL - AND m.state IN ('opened','merged','closed') - AND (n.position_new_path {path_op} - OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op})) - AND n.created_at >= ?2 - AND (?3 IS NULL OR n.project_id = ?3) - AND m.author_username IN ({in_clause}) - GROUP BY m.author_username, m.id - - UNION ALL - - -- 3. MR author via file changes (matches both new_path and old_path) - SELECT - m.author_username AS username, - 'author' AS role, - m.id AS mr_id, - (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref, - m.title AS title, - 0 AS note_count, - m.updated_at AS last_activity - FROM mr_file_changes fc - JOIN merge_requests m ON fc.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - WHERE m.author_username IS NOT NULL - AND m.state IN ('opened','merged','closed') - AND (fc.new_path {path_op} - OR (fc.old_path IS NOT NULL AND fc.old_path {path_op})) - AND m.updated_at >= ?2 - AND (?3 IS NULL OR fc.project_id = ?3) - AND m.author_username IN ({in_clause}) - - UNION ALL - - -- 4. MR reviewer via file changes + mr_reviewers (matches both new_path and old_path) - SELECT - r.username AS username, - 'reviewer' AS role, - m.id AS mr_id, - (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref, - m.title AS title, - 0 AS note_count, - m.updated_at AS last_activity - FROM mr_file_changes fc - JOIN merge_requests m ON fc.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - JOIN mr_reviewers r ON r.merge_request_id = m.id - WHERE r.username IS NOT NULL - AND (m.author_username IS NULL OR r.username != m.author_username) - AND m.state IN ('opened','merged','closed') - AND (fc.new_path {path_op} - OR (fc.old_path IS NOT NULL AND fc.old_path {path_op})) - AND m.updated_at >= ?2 - AND (?3 IS NULL OR fc.project_id = ?3) - AND r.username IN ({in_clause}) - ) - SELECT - username, - mr_ref, - title, - GROUP_CONCAT(DISTINCT role) AS roles, - SUM(note_count) AS total_notes, - MAX(last_activity) AS last_activity - FROM signals - GROUP BY username, mr_ref - ORDER BY username ASC, last_activity DESC - " - ); - - // prepare() not prepare_cached(): the IN clause varies by expert count, - // so the SQL shape changes per invocation and caching wastes memory. - let mut stmt = conn.prepare(&sql)?; - - // Build params: ?1=path, ?2=since_ms, ?3=project_id, ?4..=usernames - let mut params: Vec> = Vec::new(); - params.push(Box::new(pq.value.clone())); - params.push(Box::new(since_ms)); - params.push(Box::new(project_id)); - for expert in experts { - params.push(Box::new(expert.username.clone())); - } - let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - - let rows: Vec<(String, String, String, String, u32, i64)> = stmt - .query_map(param_refs.as_slice(), |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get::<_, String>(3)?, - row.get(4)?, - row.get(5)?, - )) - })? - .collect::, _>>()?; - - let mut map: HashMap> = HashMap::new(); - for (username, mr_ref, title, roles_csv, note_count, last_activity) in rows { - let has_author = roles_csv.contains("author"); - let has_reviewer = roles_csv.contains("reviewer"); - let role = match (has_author, has_reviewer) { - (true, true) => "A+R", - (true, false) => "A", - (false, true) => "R", - _ => "?", - } - .to_string(); - map.entry(username).or_default().push(ExpertMrDetail { - mr_ref, - title, - role, - note_count, - last_activity_ms: last_activity, - }); - } - - Ok(map) -} - -// ─── Query: Workload Mode ─────────────────────────────────────────────────── - -pub fn query_workload( - conn: &Connection, - username: &str, - project_id: Option, - since_ms: Option, - limit: usize, - include_closed: bool, -) -> Result { - let limit_plus_one = (limit + 1) as i64; - - // Query 1: Open issues assigned to user - let issues_sql = "SELECT i.iid, - (p.path_with_namespace || '#' || i.iid) AS ref, - i.title, p.path_with_namespace, i.updated_at - FROM issues i - JOIN issue_assignees ia ON ia.issue_id = i.id - JOIN projects p ON i.project_id = p.id - WHERE ia.username = ?1 - AND i.state = 'opened' - AND (?2 IS NULL OR i.project_id = ?2) - AND (?3 IS NULL OR i.updated_at >= ?3) - ORDER BY i.updated_at DESC - LIMIT ?4"; - - let mut stmt = conn.prepare_cached(issues_sql)?; - let assigned_issues: Vec = stmt - .query_map( - rusqlite::params![username, project_id, since_ms, limit_plus_one], - |row| { - Ok(WorkloadIssue { - iid: row.get(0)?, - ref_: row.get(1)?, - title: row.get(2)?, - project_path: row.get(3)?, - updated_at: row.get(4)?, - }) - }, - )? - .collect::, _>>()?; - - // Query 2: Open MRs authored - let authored_sql = "SELECT m.iid, - (p.path_with_namespace || '!' || m.iid) AS ref, - m.title, m.draft, p.path_with_namespace, m.updated_at - FROM merge_requests m - JOIN projects p ON m.project_id = p.id - WHERE m.author_username = ?1 - AND m.state = 'opened' - AND (?2 IS NULL OR m.project_id = ?2) - AND (?3 IS NULL OR m.updated_at >= ?3) - ORDER BY m.updated_at DESC - LIMIT ?4"; - let mut stmt = conn.prepare_cached(authored_sql)?; - let authored_mrs: Vec = stmt - .query_map( - rusqlite::params![username, project_id, since_ms, limit_plus_one], - |row| { - Ok(WorkloadMr { - iid: row.get(0)?, - ref_: row.get(1)?, - title: row.get(2)?, - draft: row.get::<_, i32>(3)? != 0, - project_path: row.get(4)?, - author_username: None, - updated_at: row.get(5)?, - }) - }, - )? - .collect::, _>>()?; - - // Query 3: Open MRs where user is reviewer - let reviewing_sql = "SELECT m.iid, - (p.path_with_namespace || '!' || m.iid) AS ref, - m.title, m.draft, p.path_with_namespace, - m.author_username, m.updated_at - FROM merge_requests m - JOIN mr_reviewers r ON r.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - WHERE r.username = ?1 - AND m.state = 'opened' - AND (?2 IS NULL OR m.project_id = ?2) - AND (?3 IS NULL OR m.updated_at >= ?3) - ORDER BY m.updated_at DESC - LIMIT ?4"; - let mut stmt = conn.prepare_cached(reviewing_sql)?; - let reviewing_mrs: Vec = stmt - .query_map( - rusqlite::params![username, project_id, since_ms, limit_plus_one], - |row| { - Ok(WorkloadMr { - iid: row.get(0)?, - ref_: row.get(1)?, - title: row.get(2)?, - draft: row.get::<_, i32>(3)? != 0, - project_path: row.get(4)?, - author_username: row.get(5)?, - updated_at: row.get(6)?, - }) - }, - )? - .collect::, _>>()?; - - // Query 4: Unresolved discussions where user participated - let state_filter = if include_closed { - "" - } else { - " AND (i.id IS NULL OR i.state = 'opened') - AND (m.id IS NULL OR m.state = 'opened')" - }; - let disc_sql = format!( - "SELECT d.noteable_type, - COALESCE(i.iid, m.iid) AS entity_iid, - (p.path_with_namespace || - CASE WHEN d.noteable_type = 'MergeRequest' THEN '!' ELSE '#' END || - COALESCE(i.iid, m.iid)) AS ref, - COALESCE(i.title, m.title) AS entity_title, - p.path_with_namespace, - d.last_note_at - FROM discussions d - JOIN projects p ON d.project_id = p.id - LEFT JOIN issues i ON d.issue_id = i.id - LEFT JOIN merge_requests m ON d.merge_request_id = m.id - WHERE d.resolvable = 1 AND d.resolved = 0 - AND EXISTS ( - SELECT 1 FROM notes n - WHERE n.discussion_id = d.id - AND n.author_username = ?1 - AND n.is_system = 0 - ) - AND (?2 IS NULL OR d.project_id = ?2) - AND (?3 IS NULL OR d.last_note_at >= ?3) - {state_filter} - ORDER BY d.last_note_at DESC - LIMIT ?4" - ); - - let mut stmt = conn.prepare_cached(&disc_sql)?; - let unresolved_discussions: Vec = stmt - .query_map( - rusqlite::params![username, project_id, since_ms, limit_plus_one], - |row| { - let noteable_type: String = row.get(0)?; - let entity_type = if noteable_type == "MergeRequest" { - "MR" - } else { - "Issue" - }; - Ok(WorkloadDiscussion { - entity_type: entity_type.to_string(), - entity_iid: row.get(1)?, - ref_: row.get(2)?, - entity_title: row.get(3)?, - project_path: row.get(4)?, - last_note_at: row.get(5)?, - }) - }, - )? - .collect::, _>>()?; - - // Truncation detection - let assigned_issues_truncated = assigned_issues.len() > limit; - let authored_mrs_truncated = authored_mrs.len() > limit; - let reviewing_mrs_truncated = reviewing_mrs.len() > limit; - let unresolved_discussions_truncated = unresolved_discussions.len() > limit; - - let assigned_issues: Vec = assigned_issues.into_iter().take(limit).collect(); - let authored_mrs: Vec = authored_mrs.into_iter().take(limit).collect(); - let reviewing_mrs: Vec = reviewing_mrs.into_iter().take(limit).collect(); - let unresolved_discussions: Vec = - unresolved_discussions.into_iter().take(limit).collect(); - - Ok(WorkloadResult { - username: username.to_string(), - assigned_issues, - authored_mrs, - reviewing_mrs, - unresolved_discussions, - assigned_issues_truncated, - authored_mrs_truncated, - reviewing_mrs_truncated, - unresolved_discussions_truncated, - }) -} - -// ─── Query: Reviews Mode ──────────────────────────────────────────────────── - -pub fn query_reviews( - conn: &Connection, - username: &str, - project_id: Option, - since_ms: i64, -) -> Result { - // Force the partial index on DiffNote queries (same rationale as expert mode). - // COUNT + COUNT(DISTINCT) + category extraction all benefit from 26K DiffNote - // scan vs 282K notes full scan: measured 25x speedup. - let total_sql = "SELECT COUNT(*) FROM notes n - INDEXED BY idx_notes_diffnote_path_created - JOIN discussions d ON n.discussion_id = d.id - JOIN merge_requests m ON d.merge_request_id = m.id - WHERE n.author_username = ?1 - AND n.note_type = 'DiffNote' - AND n.is_system = 0 - AND (m.author_username IS NULL OR m.author_username != ?1) - AND n.created_at >= ?2 - AND (?3 IS NULL OR n.project_id = ?3)"; - - let total_diffnotes: u32 = conn.query_row( - total_sql, - rusqlite::params![username, since_ms, project_id], - |row| row.get(0), - )?; - - // Count distinct MRs reviewed - let mrs_sql = "SELECT COUNT(DISTINCT m.id) FROM notes n - INDEXED BY idx_notes_diffnote_path_created - JOIN discussions d ON n.discussion_id = d.id - JOIN merge_requests m ON d.merge_request_id = m.id - WHERE n.author_username = ?1 - AND n.note_type = 'DiffNote' - AND n.is_system = 0 - AND (m.author_username IS NULL OR m.author_username != ?1) - AND n.created_at >= ?2 - AND (?3 IS NULL OR n.project_id = ?3)"; - - let mrs_reviewed: u32 = conn.query_row( - mrs_sql, - rusqlite::params![username, since_ms, project_id], - |row| row.get(0), - )?; - - // Extract prefixed categories: body starts with **prefix** - let cat_sql = "SELECT - SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix, - COUNT(*) AS cnt - FROM notes n INDEXED BY idx_notes_diffnote_path_created - JOIN discussions d ON n.discussion_id = d.id - JOIN merge_requests m ON d.merge_request_id = m.id - WHERE n.author_username = ?1 - AND n.note_type = 'DiffNote' - AND n.is_system = 0 - AND (m.author_username IS NULL OR m.author_username != ?1) - AND ltrim(n.body) LIKE '**%**%' - AND n.created_at >= ?2 - AND (?3 IS NULL OR n.project_id = ?3) - GROUP BY raw_prefix - ORDER BY cnt DESC"; - - let mut stmt = conn.prepare_cached(cat_sql)?; - let raw_categories: Vec<(String, u32)> = stmt - .query_map(rusqlite::params![username, since_ms, project_id], |row| { - Ok((row.get::<_, String>(0)?, row.get(1)?)) - })? - .collect::, _>>()?; - - // Normalize categories: lowercase, strip trailing colon/space, - // merge nit/nitpick variants, merge (non-blocking) variants - let mut merged: HashMap = HashMap::new(); - for (raw, count) in &raw_categories { - let normalized = normalize_review_prefix(raw); - if !normalized.is_empty() { - *merged.entry(normalized).or_insert(0) += count; - } - } - - let categorized_count: u32 = merged.values().sum(); - - let mut categories: Vec = merged - .into_iter() - .map(|(name, count)| { - let percentage = if categorized_count > 0 { - f64::from(count) / f64::from(categorized_count) * 100.0 - } else { - 0.0 - }; - ReviewCategory { - name, - count, - percentage, - } - }) - .collect(); - - categories.sort_by_key(|cat| std::cmp::Reverse(cat.count)); - - Ok(ReviewsResult { - username: username.to_string(), - total_diffnotes, - categorized_count, - mrs_reviewed, - categories, - }) -} - -/// Normalize a raw review prefix like "Suggestion (non-blocking):" into "suggestion". -fn normalize_review_prefix(raw: &str) -> String { - let s = raw.trim().trim_end_matches(':').trim().to_lowercase(); - - // Strip "(non-blocking)" and similar parentheticals - let s = if let Some(idx) = s.find('(') { - s[..idx].trim().to_string() - } else { - s - }; - - // Merge nit/nitpick variants - match s.as_str() { - "nitpick" | "nit" => "nit".to_string(), - other => other.to_string(), - } -} - -// ─── Query: Active Mode ───────────────────────────────────────────────────── - -pub fn query_active( - conn: &Connection, - project_id: Option, - since_ms: i64, - limit: usize, - include_closed: bool, -) -> Result { - let limit_plus_one = (limit + 1) as i64; - - // State filter for open-entities-only (default behavior) - let state_joins = if include_closed { - "" - } else { - " LEFT JOIN issues i ON d.issue_id = i.id - LEFT JOIN merge_requests m ON d.merge_request_id = m.id" - }; - let state_filter = if include_closed { - "" - } else { - " AND (i.id IS NULL OR i.state = 'opened') - AND (m.id IS NULL OR m.state = 'opened')" - }; - - // Total unresolved count -- conditionally built - let total_sql_global = format!( - "SELECT COUNT(*) FROM discussions d - {state_joins} - WHERE d.resolvable = 1 AND d.resolved = 0 - AND d.last_note_at >= ?1 - {state_filter}" - ); - let total_sql_scoped = format!( - "SELECT COUNT(*) FROM discussions d - {state_joins} - WHERE d.resolvable = 1 AND d.resolved = 0 - AND d.last_note_at >= ?1 - AND d.project_id = ?2 - {state_filter}" - ); - - let total_unresolved_in_window: u32 = match project_id { - None => conn.query_row(&total_sql_global, rusqlite::params![since_ms], |row| { - row.get(0) - })?, - Some(pid) => { - conn.query_row(&total_sql_scoped, rusqlite::params![since_ms, pid], |row| { - row.get(0) - })? - } - }; - - // Active discussions with context -- conditionally built SQL - let sql_global = format!( - " - WITH picked AS ( - SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id, - d.project_id, d.last_note_at - FROM discussions d - {state_joins} - WHERE d.resolvable = 1 AND d.resolved = 0 - AND d.last_note_at >= ?1 - {state_filter} - ORDER BY d.last_note_at DESC - LIMIT ?2 - ), - note_counts AS ( - SELECT - n.discussion_id, - COUNT(*) AS note_count - FROM notes n - JOIN picked p ON p.id = n.discussion_id - WHERE n.is_system = 0 - GROUP BY n.discussion_id - ), - participants AS ( - SELECT - x.discussion_id, - GROUP_CONCAT(x.author_username, X'1F') AS participants - FROM ( - SELECT DISTINCT n.discussion_id, n.author_username - FROM notes n - JOIN picked p ON p.id = n.discussion_id - WHERE n.is_system = 0 AND n.author_username IS NOT NULL - ) x - GROUP BY x.discussion_id - ) - SELECT - p.id AS discussion_id, - p.noteable_type, - COALESCE(i.iid, m.iid) AS entity_iid, - COALESCE(i.title, m.title) AS entity_title, - proj.path_with_namespace, - p.last_note_at, - COALESCE(nc.note_count, 0) AS note_count, - COALESCE(pa.participants, '') AS participants - FROM picked p - JOIN projects proj ON p.project_id = proj.id - LEFT JOIN issues i ON p.issue_id = i.id - LEFT JOIN merge_requests m ON p.merge_request_id = m.id - LEFT JOIN note_counts nc ON nc.discussion_id = p.id - LEFT JOIN participants pa ON pa.discussion_id = p.id - ORDER BY p.last_note_at DESC - " - ); - - let sql_scoped = format!( - " - WITH picked AS ( - SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id, - d.project_id, d.last_note_at - FROM discussions d - {state_joins} - WHERE d.resolvable = 1 AND d.resolved = 0 - AND d.last_note_at >= ?1 - AND d.project_id = ?2 - {state_filter} - ORDER BY d.last_note_at DESC - LIMIT ?3 - ), - note_counts AS ( - SELECT - n.discussion_id, - COUNT(*) AS note_count - FROM notes n - JOIN picked p ON p.id = n.discussion_id - WHERE n.is_system = 0 - GROUP BY n.discussion_id - ), - participants AS ( - SELECT - x.discussion_id, - GROUP_CONCAT(x.author_username, X'1F') AS participants - FROM ( - SELECT DISTINCT n.discussion_id, n.author_username - FROM notes n - JOIN picked p ON p.id = n.discussion_id - WHERE n.is_system = 0 AND n.author_username IS NOT NULL - ) x - GROUP BY x.discussion_id - ) - SELECT - p.id AS discussion_id, - p.noteable_type, - COALESCE(i.iid, m.iid) AS entity_iid, - COALESCE(i.title, m.title) AS entity_title, - proj.path_with_namespace, - p.last_note_at, - COALESCE(nc.note_count, 0) AS note_count, - COALESCE(pa.participants, '') AS participants - FROM picked p - JOIN projects proj ON p.project_id = proj.id - LEFT JOIN issues i ON p.issue_id = i.id - LEFT JOIN merge_requests m ON p.merge_request_id = m.id - LEFT JOIN note_counts nc ON nc.discussion_id = p.id - LEFT JOIN participants pa ON pa.discussion_id = p.id - ORDER BY p.last_note_at DESC - " - ); - - // Row-mapping closure shared between both variants - let map_row = |row: &rusqlite::Row| -> rusqlite::Result { - let noteable_type: String = row.get(1)?; - let entity_type = if noteable_type == "MergeRequest" { - "MR" - } else { - "Issue" - }; - let participants_csv: Option = row.get(7)?; - // Sort participants for deterministic output -- GROUP_CONCAT order is undefined - let mut participants: Vec = participants_csv - .as_deref() - .filter(|s| !s.is_empty()) - .map(|csv| csv.split('\x1F').map(String::from).collect()) - .unwrap_or_default(); - participants.sort(); - - const MAX_PARTICIPANTS: usize = 50; - let participants_total = participants.len() as u32; - let participants_truncated = participants.len() > MAX_PARTICIPANTS; - if participants_truncated { - participants.truncate(MAX_PARTICIPANTS); - } - - Ok(ActiveDiscussion { - discussion_id: row.get(0)?, - entity_type: entity_type.to_string(), - entity_iid: row.get(2)?, - entity_title: row.get(3)?, - project_path: row.get(4)?, - last_note_at: row.get(5)?, - note_count: row.get(6)?, - participants, - participants_total, - participants_truncated, - }) - }; - - // Select variant first, then prepare exactly one statement - let discussions: Vec = match project_id { - None => { - let mut stmt = conn.prepare_cached(&sql_global)?; - stmt.query_map(rusqlite::params![since_ms, limit_plus_one], &map_row)? - .collect::, _>>()? - } - Some(pid) => { - let mut stmt = conn.prepare_cached(&sql_scoped)?; - stmt.query_map(rusqlite::params![since_ms, pid, limit_plus_one], &map_row)? - .collect::, _>>()? - } - }; - - let truncated = discussions.len() > limit; - let discussions: Vec = discussions.into_iter().take(limit).collect(); - - Ok(ActiveResult { - discussions, - total_unresolved_in_window, - truncated, - }) -} - -// ─── Query: Overlap Mode ──────────────────────────────────────────────────── - -pub fn query_overlap( - conn: &Connection, - path: &str, - project_id: Option, - since_ms: i64, - limit: usize, -) -> Result { - let pq = build_path_query(conn, path, project_id)?; - - // Build SQL with 4 signal sources, matching the expert query expansion. - // Each row produces (username, role, mr_id, mr_ref, seen_at) for Rust-side accumulation. - let path_op = if pq.is_prefix { - "LIKE ?1 ESCAPE '\\'" - } else { - "= ?1" - }; - // Match both new_path and old_path to capture activity on renamed files. - // INDEXED BY removed to allow OR across path columns; overlap runs once - // per command so the minor plan difference is acceptable. - let sql = format!( - "SELECT username, role, touch_count, last_seen_at, mr_refs FROM ( - -- 1. DiffNote reviewer (matches both new_path and old_path) - SELECT - n.author_username AS username, - 'reviewer' AS role, - COUNT(DISTINCT m.id) AS touch_count, - MAX(n.created_at) AS last_seen_at, - GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs - FROM notes n - JOIN discussions d ON n.discussion_id = d.id - JOIN merge_requests m ON d.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - WHERE n.note_type = 'DiffNote' - AND (n.position_new_path {path_op} - OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op})) - AND n.is_system = 0 - AND n.author_username IS NOT NULL - AND (m.author_username IS NULL OR n.author_username != m.author_username) - AND m.state IN ('opened','merged','closed') - AND n.created_at >= ?2 - AND (?3 IS NULL OR n.project_id = ?3) - GROUP BY n.author_username - - UNION ALL - - -- 2. DiffNote MR author (matches both new_path and old_path) - SELECT - m.author_username AS username, - 'author' AS role, - COUNT(DISTINCT m.id) AS touch_count, - MAX(n.created_at) AS last_seen_at, - GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs - FROM notes n - JOIN discussions d ON n.discussion_id = d.id - JOIN merge_requests m ON d.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - WHERE n.note_type = 'DiffNote' - AND (n.position_new_path {path_op} - OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op})) - AND n.is_system = 0 - AND m.state IN ('opened','merged','closed') - AND m.author_username IS NOT NULL - AND n.created_at >= ?2 - AND (?3 IS NULL OR n.project_id = ?3) - GROUP BY m.author_username - - UNION ALL - - -- 3. MR author via file changes (matches both new_path and old_path) - SELECT - m.author_username AS username, - 'author' AS role, - COUNT(DISTINCT m.id) AS touch_count, - MAX(m.updated_at) AS last_seen_at, - GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs - FROM mr_file_changes fc - JOIN merge_requests m ON fc.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - WHERE m.author_username IS NOT NULL - AND m.state IN ('opened','merged','closed') - AND (fc.new_path {path_op} - OR (fc.old_path IS NOT NULL AND fc.old_path {path_op})) - AND m.updated_at >= ?2 - AND (?3 IS NULL OR fc.project_id = ?3) - GROUP BY m.author_username - - UNION ALL - - -- 4. MR reviewer via file changes + mr_reviewers (matches both new_path and old_path) - SELECT - r.username AS username, - 'reviewer' AS role, - COUNT(DISTINCT m.id) AS touch_count, - MAX(m.updated_at) AS last_seen_at, - GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs - FROM mr_file_changes fc - JOIN merge_requests m ON fc.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - JOIN mr_reviewers r ON r.merge_request_id = m.id - WHERE r.username IS NOT NULL - AND (m.author_username IS NULL OR r.username != m.author_username) - AND m.state IN ('opened','merged','closed') - AND (fc.new_path {path_op} - OR (fc.old_path IS NOT NULL AND fc.old_path {path_op})) - AND m.updated_at >= ?2 - AND (?3 IS NULL OR fc.project_id = ?3) - GROUP BY r.username - )" - ); - - let mut stmt = conn.prepare_cached(&sql)?; - let rows: Vec<(String, String, u32, i64, Option)> = stmt - .query_map(rusqlite::params![pq.value, since_ms, project_id], |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - )) - })? - .collect::, _>>()?; - - // Internal accumulator uses HashSet for MR refs from the start - struct OverlapAcc { - username: String, - author_touch_count: u32, - review_touch_count: u32, - touch_count: u32, - last_seen_at: i64, - mr_refs: HashSet, - } - - let mut user_map: HashMap = HashMap::new(); - for (username, role, count, last_seen, mr_refs_csv) in &rows { - let mr_refs: Vec = mr_refs_csv - .as_deref() - .map(|csv| csv.split(',').map(|s| s.trim().to_string()).collect()) - .unwrap_or_default(); - - let entry = user_map - .entry(username.clone()) - .or_insert_with(|| OverlapAcc { - username: username.clone(), - author_touch_count: 0, - review_touch_count: 0, - touch_count: 0, - last_seen_at: 0, - mr_refs: HashSet::new(), - }); - entry.touch_count += count; - if role == "author" { - entry.author_touch_count += count; - } else { - entry.review_touch_count += count; - } - if *last_seen > entry.last_seen_at { - entry.last_seen_at = *last_seen; - } - for r in mr_refs { - entry.mr_refs.insert(r); - } - } - - // Convert accumulators to output structs - let mut users: Vec = user_map - .into_values() - .map(|a| { - let mut mr_refs: Vec = a.mr_refs.into_iter().collect(); - mr_refs.sort(); - let mr_refs_total = mr_refs.len() as u32; - let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER; - if mr_refs_truncated { - mr_refs.truncate(MAX_MR_REFS_PER_USER); - } - OverlapUser { - username: a.username, - author_touch_count: a.author_touch_count, - review_touch_count: a.review_touch_count, - touch_count: a.touch_count, - last_seen_at: a.last_seen_at, - mr_refs, - mr_refs_total, - mr_refs_truncated, - } - }) - .collect(); - - // Stable sort with full tie-breakers for deterministic output - users.sort_by(|a, b| { - b.touch_count - .cmp(&a.touch_count) - .then_with(|| b.last_seen_at.cmp(&a.last_seen_at)) - .then_with(|| a.username.cmp(&b.username)) - }); - - let truncated = users.len() > limit; - users.truncate(limit); - - Ok(OverlapResult { - path_query: if pq.is_prefix { - path.trim_end_matches('/').to_string() - } else { - pq.value.clone() - }, - path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(), - users, - truncated, - }) -} - -/// Format overlap role for display: "A", "R", or "A+R". -fn format_overlap_role(user: &OverlapUser) -> &'static str { - match (user.author_touch_count > 0, user.review_touch_count > 0) { - (true, true) => "A+R", - (true, false) => "A", - (false, true) => "R", - (false, false) => "-", - } -} - -// ─── Human Output ──────────────────────────────────────────────────────────── - -pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) { - match result { - WhoResult::Expert(r) => print_expert_human(r, project_path), - WhoResult::Workload(r) => print_workload_human(r), - WhoResult::Reviews(r) => print_reviews_human(r), - WhoResult::Active(r) => print_active_human(r, project_path), - WhoResult::Overlap(r) => print_overlap_human(r, project_path), - } -} - -/// Print a dim hint when results aggregate across all projects. -fn print_scope_hint(project_path: Option<&str>) { - if project_path.is_none() { - println!( - " {}", - Theme::dim().render("(aggregated across all projects; use -p to scope)") - ); - } -} - -fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) { - println!(); - println!( - "{}", - Theme::bold().render(&format!("Experts for {}", r.path_query)) - ); - println!("{}", "\u{2500}".repeat(60)); - println!( - " {}", - Theme::dim().render(&format!( - "(matching {} {})", - r.path_match, - if r.path_match == "exact" { - "file" - } else { - "directory prefix" - } - )) - ); - print_scope_hint(project_path); - println!(); - - if r.experts.is_empty() { - println!( - " {}", - Theme::dim().render("No experts found for this path.") - ); - println!(); - return; - } - - println!( - " {:<16} {:>6} {:>12} {:>6} {:>12} {} {}", - Theme::bold().render("Username"), - Theme::bold().render("Score"), - Theme::bold().render("Reviewed(MRs)"), - Theme::bold().render("Notes"), - Theme::bold().render("Authored(MRs)"), - Theme::bold().render("Last Seen"), - Theme::bold().render("MR Refs"), - ); - - for expert in &r.experts { - let reviews = if expert.review_mr_count > 0 { - expert.review_mr_count.to_string() - } else { - "-".to_string() - }; - let notes = if expert.review_note_count > 0 { - expert.review_note_count.to_string() - } else { - "-".to_string() - }; - let authored = if expert.author_mr_count > 0 { - expert.author_mr_count.to_string() - } else { - "-".to_string() - }; - let mr_str = expert - .mr_refs - .iter() - .take(5) - .cloned() - .collect::>() - .join(", "); - let overflow = if expert.mr_refs_total > 5 { - format!(" +{}", expert.mr_refs_total - 5) - } else { - String::new() - }; - println!( - " {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}", - Theme::info().render(&format!("{} {}", Icons::user(), expert.username)), - expert.score, - reviews, - notes, - authored, - render::format_relative_time(expert.last_seen_ms), - if mr_str.is_empty() { - String::new() - } else { - format!(" {mr_str}") - }, - overflow, - ); - - // Print detail sub-rows when populated - if let Some(details) = &expert.details { - const MAX_DETAIL_DISPLAY: usize = 10; - for d in details.iter().take(MAX_DETAIL_DISPLAY) { - let notes_str = if d.note_count > 0 { - format!("{} notes", d.note_count) - } else { - String::new() - }; - println!( - " {:<3} {:<30} {:>30} {:>10} {}", - Theme::dim().render(&d.role), - d.mr_ref, - render::truncate(&format!("\"{}\"", d.title), 30), - notes_str, - Theme::dim().render(&render::format_relative_time(d.last_activity_ms)), - ); - } - if details.len() > MAX_DETAIL_DISPLAY { - println!( - " {}", - Theme::dim().render(&format!("+{} more", details.len() - MAX_DETAIL_DISPLAY)) - ); - } - } - } - if r.truncated { - println!( - " {}", - Theme::dim().render("(showing first -n; rerun with a higher --limit)") - ); - } - println!(); -} - -fn print_workload_human(r: &WorkloadResult) { - println!(); - println!( - "{}", - Theme::bold().render(&format!( - "{} {} -- Workload Summary", - Icons::user(), - r.username - )) - ); - println!("{}", "\u{2500}".repeat(60)); - - if !r.assigned_issues.is_empty() { - println!( - "{}", - render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len())) - ); - for item in &r.assigned_issues { - println!( - " {} {} {}", - Theme::info().render(&item.ref_), - render::truncate(&item.title, 40), - Theme::dim().render(&render::format_relative_time(item.updated_at)), - ); - } - if r.assigned_issues_truncated { - println!( - " {}", - Theme::dim().render("(truncated; rerun with a higher --limit)") - ); - } - } - - if !r.authored_mrs.is_empty() { - println!( - "{}", - render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len())) - ); - for mr in &r.authored_mrs { - let draft = if mr.draft { " [draft]" } else { "" }; - println!( - " {} {}{} {}", - Theme::info().render(&mr.ref_), - render::truncate(&mr.title, 35), - Theme::dim().render(draft), - Theme::dim().render(&render::format_relative_time(mr.updated_at)), - ); - } - if r.authored_mrs_truncated { - println!( - " {}", - Theme::dim().render("(truncated; rerun with a higher --limit)") - ); - } - } - - if !r.reviewing_mrs.is_empty() { - println!( - "{}", - render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len())) - ); - for mr in &r.reviewing_mrs { - let author = mr - .author_username - .as_deref() - .map(|a| format!(" by @{a}")) - .unwrap_or_default(); - println!( - " {} {}{} {}", - Theme::info().render(&mr.ref_), - render::truncate(&mr.title, 30), - Theme::dim().render(&author), - Theme::dim().render(&render::format_relative_time(mr.updated_at)), - ); - } - if r.reviewing_mrs_truncated { - println!( - " {}", - Theme::dim().render("(truncated; rerun with a higher --limit)") - ); - } - } - - if !r.unresolved_discussions.is_empty() { - println!( - "{}", - render::section_divider(&format!( - "Unresolved Discussions ({})", - r.unresolved_discussions.len() - )) - ); - for disc in &r.unresolved_discussions { - println!( - " {} {} {} {}", - Theme::dim().render(&disc.entity_type), - Theme::info().render(&disc.ref_), - render::truncate(&disc.entity_title, 35), - Theme::dim().render(&render::format_relative_time(disc.last_note_at)), - ); - } - if r.unresolved_discussions_truncated { - println!( - " {}", - Theme::dim().render("(truncated; rerun with a higher --limit)") - ); - } - } - - if r.assigned_issues.is_empty() - && r.authored_mrs.is_empty() - && r.reviewing_mrs.is_empty() - && r.unresolved_discussions.is_empty() - { - println!(); - println!( - " {}", - Theme::dim().render("No open work items found for this user.") - ); - } - - println!(); -} - -fn print_reviews_human(r: &ReviewsResult) { - println!(); - println!( - "{}", - Theme::bold().render(&format!( - "{} {} -- Review Patterns", - Icons::user(), - r.username - )) - ); - println!("{}", "\u{2500}".repeat(60)); - println!(); - - if r.total_diffnotes == 0 { - println!( - " {}", - Theme::dim().render("No review comments found for this user.") - ); - println!(); - return; - } - - println!( - " {} DiffNotes across {} MRs ({} categorized)", - Theme::bold().render(&r.total_diffnotes.to_string()), - Theme::bold().render(&r.mrs_reviewed.to_string()), - Theme::bold().render(&r.categorized_count.to_string()), - ); - println!(); - - if !r.categories.is_empty() { - println!( - " {:<16} {:>6} {:>6}", - Theme::bold().render("Category"), - Theme::bold().render("Count"), - Theme::bold().render("%"), - ); - - for cat in &r.categories { - println!( - " {:<16} {:>6} {:>5.1}%", - Theme::info().render(&cat.name), - cat.count, - cat.percentage, - ); - } - } - - let uncategorized = r.total_diffnotes - r.categorized_count; - if uncategorized > 0 { - println!(); - println!( - " {} {} uncategorized (no **prefix** convention)", - Theme::dim().render("Note:"), - uncategorized, - ); - } - - println!(); -} - -fn print_active_human(r: &ActiveResult, project_path: Option<&str>) { - println!(); - println!( - "{}", - Theme::bold().render(&format!( - "Active Discussions ({} unresolved in window)", - r.total_unresolved_in_window - )) - ); - println!("{}", "\u{2500}".repeat(60)); - print_scope_hint(project_path); - println!(); - - if r.discussions.is_empty() { - println!( - " {}", - Theme::dim().render("No active unresolved discussions in this time window.") - ); - println!(); - return; - } - - for disc in &r.discussions { - let prefix = if disc.entity_type == "MR" { "!" } else { "#" }; - let participants_str = disc - .participants - .iter() - .map(|p| format!("@{p}")) - .collect::>() - .join(", "); - - println!( - " {} {} {} {} notes {}", - Theme::info().render(&format!("{prefix}{}", disc.entity_iid)), - render::truncate(&disc.entity_title, 40), - Theme::dim().render(&render::format_relative_time(disc.last_note_at)), - disc.note_count, - Theme::dim().render(&disc.project_path), - ); - if !participants_str.is_empty() { - println!(" {}", Theme::dim().render(&participants_str)); - } - } - if r.truncated { - println!( - " {}", - Theme::dim().render("(showing first -n; rerun with a higher --limit)") - ); - } - println!(); -} - -fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) { - println!(); - println!( - "{}", - Theme::bold().render(&format!("Overlap for {}", r.path_query)) - ); - println!("{}", "\u{2500}".repeat(60)); - println!( - " {}", - Theme::dim().render(&format!( - "(matching {} {})", - r.path_match, - if r.path_match == "exact" { - "file" - } else { - "directory prefix" - } - )) - ); - print_scope_hint(project_path); - println!(); - - if r.users.is_empty() { - println!( - " {}", - Theme::dim().render("No overlapping users found for this path.") - ); - println!(); - return; - } - - println!( - " {:<16} {:<6} {:>7} {:<12} {}", - Theme::bold().render("Username"), - Theme::bold().render("Role"), - Theme::bold().render("MRs"), - Theme::bold().render("Last Seen"), - Theme::bold().render("MR Refs"), - ); - - for user in &r.users { - let mr_str = user - .mr_refs - .iter() - .take(5) - .cloned() - .collect::>() - .join(", "); - let overflow = if user.mr_refs.len() > 5 { - format!(" +{}", user.mr_refs.len() - 5) - } else { - String::new() - }; - - println!( - " {:<16} {:<6} {:>7} {:<12} {}{}", - Theme::info().render(&format!("{} {}", Icons::user(), user.username)), - format_overlap_role(user), - user.touch_count, - render::format_relative_time(user.last_seen_at), - mr_str, - overflow, - ); - } - if r.truncated { - println!( - " {}", - Theme::dim().render("(showing first -n; rerun with a higher --limit)") - ); - } - println!(); -} - -// ─── Robot JSON Output ─────────────────────────────────────────────────────── - -pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) { - let (mode, data) = match &run.result { - WhoResult::Expert(r) => ("expert", expert_to_json(r)), - WhoResult::Workload(r) => ("workload", workload_to_json(r)), - WhoResult::Reviews(r) => ("reviews", reviews_to_json(r)), - WhoResult::Active(r) => ("active", active_to_json(r)), - WhoResult::Overlap(r) => ("overlap", overlap_to_json(r)), - }; - - // Raw CLI args -- what the user typed - let input = serde_json::json!({ - "target": args.target, - "path": args.path, - "project": args.project, - "since": args.since, - "limit": args.limit, - "detail": args.detail, - "as_of": args.as_of, - "explain_score": args.explain_score, - "include_bots": args.include_bots, - "all_history": args.all_history, - }); - - // Resolved/computed values -- what actually ran - let resolved_input = serde_json::json!({ - "mode": run.resolved_input.mode, - "project_id": run.resolved_input.project_id, - "project_path": run.resolved_input.project_path, - "since_ms": run.resolved_input.since_ms, - "since_iso": run.resolved_input.since_iso, - "since_mode": run.resolved_input.since_mode, - "limit": run.resolved_input.limit, - }); - - let output = WhoJsonEnvelope { - ok: true, - data: WhoJsonData { - mode: mode.to_string(), - input, - resolved_input, - result: data, - }, - meta: RobotMeta { elapsed_ms }, - }; - - let mut value = serde_json::to_value(&output).unwrap_or_else(|e| { - serde_json::json!({"ok":false,"error":{"code":"INTERNAL_ERROR","message":format!("JSON serialization failed: {e}")}}) - }); - - if let Some(f) = &args.fields { - let preset_key = format!("who_{mode}"); - let expanded = crate::cli::robot::expand_fields_preset(f, &preset_key); - // Each who mode uses a different array key; try all possible keys - for key in &[ - "experts", - "assigned_issues", - "authored_mrs", - "review_mrs", - "categories", - "discussions", - "users", - ] { - crate::cli::robot::filter_fields(&mut value, key, &expanded); - } - } - - println!("{}", serde_json::to_string(&value).unwrap()); -} - -#[derive(Serialize)] -struct WhoJsonEnvelope { - ok: bool, - data: WhoJsonData, - meta: RobotMeta, -} - -#[derive(Serialize)] -struct WhoJsonData { - mode: String, - input: serde_json::Value, - resolved_input: serde_json::Value, - #[serde(flatten)] - result: serde_json::Value, -} - -fn expert_to_json(r: &ExpertResult) -> serde_json::Value { - serde_json::json!({ - "path_query": r.path_query, - "path_match": r.path_match, - "scoring_model_version": 2, - "truncated": r.truncated, - "experts": r.experts.iter().map(|e| { - let mut obj = serde_json::json!({ - "username": e.username, - "score": e.score, - "review_mr_count": e.review_mr_count, - "review_note_count": e.review_note_count, - "author_mr_count": e.author_mr_count, - "last_seen_at": ms_to_iso(e.last_seen_ms), - "mr_refs": e.mr_refs, - "mr_refs_total": e.mr_refs_total, - "mr_refs_truncated": e.mr_refs_truncated, - }); - if let Some(raw) = e.score_raw { - obj["score_raw"] = serde_json::json!(raw); - } - if let Some(comp) = &e.components { - obj["components"] = serde_json::json!({ - "author": comp.author, - "reviewer_participated": comp.reviewer_participated, - "reviewer_assigned": comp.reviewer_assigned, - "notes": comp.notes, - }); - } - if let Some(details) = &e.details { - obj["details"] = serde_json::json!(details.iter().map(|d| serde_json::json!({ - "mr_ref": d.mr_ref, - "title": d.title, - "role": d.role, - "note_count": d.note_count, - "last_activity_at": ms_to_iso(d.last_activity_ms), - })).collect::>()); - } - obj - }).collect::>(), - }) -} - -fn workload_to_json(r: &WorkloadResult) -> serde_json::Value { - serde_json::json!({ - "username": r.username, - "assigned_issues": r.assigned_issues.iter().map(|i| serde_json::json!({ - "iid": i.iid, - "ref": i.ref_, - "title": i.title, - "project_path": i.project_path, - "updated_at": ms_to_iso(i.updated_at), - })).collect::>(), - "authored_mrs": r.authored_mrs.iter().map(|m| serde_json::json!({ - "iid": m.iid, - "ref": m.ref_, - "title": m.title, - "draft": m.draft, - "project_path": m.project_path, - "updated_at": ms_to_iso(m.updated_at), - })).collect::>(), - "reviewing_mrs": r.reviewing_mrs.iter().map(|m| serde_json::json!({ - "iid": m.iid, - "ref": m.ref_, - "title": m.title, - "draft": m.draft, - "project_path": m.project_path, - "author_username": m.author_username, - "updated_at": ms_to_iso(m.updated_at), - })).collect::>(), - "unresolved_discussions": r.unresolved_discussions.iter().map(|d| serde_json::json!({ - "entity_type": d.entity_type, - "entity_iid": d.entity_iid, - "ref": d.ref_, - "entity_title": d.entity_title, - "project_path": d.project_path, - "last_note_at": ms_to_iso(d.last_note_at), - })).collect::>(), - "summary": { - "assigned_issue_count": r.assigned_issues.len(), - "authored_mr_count": r.authored_mrs.len(), - "reviewing_mr_count": r.reviewing_mrs.len(), - "unresolved_discussion_count": r.unresolved_discussions.len(), - }, - "truncation": { - "assigned_issues_truncated": r.assigned_issues_truncated, - "authored_mrs_truncated": r.authored_mrs_truncated, - "reviewing_mrs_truncated": r.reviewing_mrs_truncated, - "unresolved_discussions_truncated": r.unresolved_discussions_truncated, - } - }) -} - -fn reviews_to_json(r: &ReviewsResult) -> serde_json::Value { - serde_json::json!({ - "username": r.username, - "total_diffnotes": r.total_diffnotes, - "categorized_count": r.categorized_count, - "mrs_reviewed": r.mrs_reviewed, - "categories": r.categories.iter().map(|c| serde_json::json!({ - "name": c.name, - "count": c.count, - "percentage": (c.percentage * 10.0).round() / 10.0, - })).collect::>(), - }) -} - -fn active_to_json(r: &ActiveResult) -> serde_json::Value { - serde_json::json!({ - "total_unresolved_in_window": r.total_unresolved_in_window, - "truncated": r.truncated, - "discussions": r.discussions.iter().map(|d| serde_json::json!({ - "discussion_id": d.discussion_id, - "entity_type": d.entity_type, - "entity_iid": d.entity_iid, - "entity_title": d.entity_title, - "project_path": d.project_path, - "last_note_at": ms_to_iso(d.last_note_at), - "note_count": d.note_count, - "participants": d.participants, - "participants_total": d.participants_total, - "participants_truncated": d.participants_truncated, - })).collect::>(), - }) -} - -fn overlap_to_json(r: &OverlapResult) -> serde_json::Value { - serde_json::json!({ - "path_query": r.path_query, - "path_match": r.path_match, - "truncated": r.truncated, - "users": r.users.iter().map(|u| serde_json::json!({ - "username": u.username, - "role": format_overlap_role(u), - "author_touch_count": u.author_touch_count, - "review_touch_count": u.review_touch_count, - "touch_count": u.touch_count, - "last_seen_at": ms_to_iso(u.last_seen_at), - "mr_refs": u.mr_refs, - "mr_refs_total": u.mr_refs_total, - "mr_refs_truncated": u.mr_refs_truncated, - })).collect::>(), - }) -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── +// ─── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] #[path = "who_tests.rs"] diff --git a/src/cli/commands/who/format.rs b/src/cli/commands/who/format.rs index 02f87a5..035dd99 100644 --- a/src/cli/commands/who/format.rs +++ b/src/cli/commands/who/format.rs @@ -8,8 +8,8 @@ use crate::core::who_types::{ ActiveResult, ExpertResult, OverlapResult, ReviewsResult, WhoResult, WorkloadResult, }; +use super::WhoRun; use super::queries::format_overlap_role; -use super::{WhoRun, WhoResolvedInput}; // ─── Human Output ──────────────────────────────────────────────────────────── diff --git a/src/cli/commands/who/queries.rs b/src/cli/commands/who/queries.rs index b1e999b..f336821 100644 --- a/src/cli/commands/who/queries.rs +++ b/src/cli/commands/who/queries.rs @@ -320,7 +320,7 @@ fn build_mr_refs_for_user(conn: &Connection, ua: &UserAccum) -> Vec { /// /// Parameters: `?1` = path, `?2` = since_ms, `?3` = project_id (nullable), /// `?4` = as_of_ms, `?5` = closed_mr_multiplier, `?6` = reviewer_min_note_chars -fn build_expert_sql_v2(is_prefix: bool) -> String { +pub(super) fn build_expert_sql_v2(is_prefix: bool) -> String { let path_op = if is_prefix { "LIKE ?1 ESCAPE '\\'" } else { @@ -967,7 +967,7 @@ pub fn query_reviews( } /// Normalize a raw review prefix like "Suggestion (non-blocking):" into "suggestion". -fn normalize_review_prefix(raw: &str) -> String { +pub(super) fn normalize_review_prefix(raw: &str) -> String { let s = raw.trim().trim_end_matches(':').trim().to_lowercase(); // Strip "(non-blocking)" and similar parentheticals