From e8ecb561cfc512008632de6c19b1bfba3e0213ae Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 19 Feb 2026 09:26:54 -0500 Subject: [PATCH] feat: implement lore explain command (bd-9lbr) Auto-generates structured narratives for issues and MRs from local DB: - EntitySummary with title, state, author, labels, status - Key decisions heuristic (correlates state/label changes with nearby notes) - Activity summary with event counts and time span - Open threads detection (unresolved discussions) - Related entities (closing MRs, related issues) - Timeline of all events in chronological order 7 unit tests, robot-docs entry, autocorrect registry, CLI dispatch wired. --- .beads/issues.jsonl | 4 +- crates/lore-tui/src/action/bootstrap.rs | 24 +- crates/lore-tui/src/lib.rs | 4 +- src/cli/autocorrect.rs | 1 + src/cli/commands/explain.rs | 166 ++- src/cli/commands/who/format.rs | 696 +++++++++++ src/cli/commands/who/queries.rs | 1431 +++++++++++++++++++++++ src/cli/commands/who/scoring.rs | 20 + src/cli/mod.rs | 14 + src/main.rs | 71 +- 10 files changed, 2352 insertions(+), 79 deletions(-) create mode 100644 src/cli/commands/who/format.rs create mode 100644 src/cli/commands/who/queries.rs create mode 100644 src/cli/commands/who/scoring.rs diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 14322cc..e3f588a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T21:24:22.040110Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:30:15.841587Z","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":"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-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"}]} @@ -266,7 +266,7 @@ {"id":"bd-91j1","title":"Comprehensive robot-docs as agent bootstrap","description":"## Background\nAgents reach for glab because they already know it from training data. lore robot-docs exists but is not comprehensive enough to serve as a zero-training bootstrap. An agent encountering lore for the first time should be able to use any command correctly after reading robot-docs output alone.\n\n## Current State (Verified 2026-02-12)\n- `handle_robot_docs()` at src/main.rs:2069\n- Called at no-args in robot mode (main.rs:165) and via Commands::RobotDocs { brief } (main.rs:229)\n- Current output top-level keys: name, version, description, activation, commands, aliases, exit_codes, clap_error_codes, error_format, workflows\n- Missing: response_schema per command, example_output per command, quick_start section, glab equivalence table\n- --brief flag exists but returns shorter version of same structure\n- main.rs is 2579 lines total\n\n## Current robot-docs Output Structure\n```json\n{\n \"name\": \"lore\",\n \"version\": \"0.6.1\",\n \"description\": \"...\",\n \"activation\": { \"flags\": [\"--robot\", \"-J\"], \"env\": \"LORE_ROBOT=1\", \"auto_detect\": \"non-TTY\" },\n \"commands\": [{ \"name\": \"...\", \"description\": \"...\", \"flags\": [...], \"example\": \"...\" }],\n \"aliases\": { ... },\n \"exit_codes\": { ... },\n \"clap_error_codes\": { ... },\n \"error_format\": { ... },\n \"workflows\": { ... }\n}\n```\n\n## Approach\n\n### 1. Add quick_start section\nTop-level key with glab-to-lore translation and lore-exclusive feature summary:\n```json\n\"quick_start\": {\n \"glab_equivalents\": [\n { \"glab\": \"glab issue list\", \"lore\": \"lore -J issues -n 50\", \"note\": \"Richer: includes labels, status, closing MRs\" },\n { \"glab\": \"glab issue view 123\", \"lore\": \"lore -J issues 123\", \"note\": \"Includes discussions, work-item status\" },\n { \"glab\": \"glab mr list\", \"lore\": \"lore -J mrs\", \"note\": \"Includes draft status, reviewers\" },\n { \"glab\": \"glab mr view 456\", \"lore\": \"lore -J mrs 456\", \"note\": \"Includes discussions, file changes\" },\n { \"glab\": \"glab api '/projects/:id/issues'\", \"lore\": \"lore -J issues -p project\", \"note\": \"Fuzzy project matching\" }\n ],\n \"lore_exclusive\": [\n \"search: FTS5 + vector hybrid search across all entities\",\n \"who: Expert/workload/reviews analysis per file path or person\",\n \"timeline: Chronological event reconstruction across entities\",\n \"stats: Database statistics with document/note/discussion counts\",\n \"count: Entity counts with state breakdowns\"\n ]\n}\n```\n\n### 2. Add response_schema per command\nFor each command in the commands array, add a `response_schema` field showing the JSON shape:\n```json\n{\n \"name\": \"issues\",\n \"response_schema\": {\n \"ok\": \"boolean\",\n \"data\": { \"type\": \"array|object\", \"fields\": [\"iid\", \"title\", \"state\", \"...\"] },\n \"meta\": { \"elapsed_ms\": \"integer\" }\n }\n}\n```\nCommands with multiple output shapes (list vs detail) need both documented.\n\n### 3. Add example_output per command\nRealistic truncated JSON for each command. Keep each example under 500 bytes.\n\n### 4. Token budget enforcement\n- --brief mode: ONLY quick_start + command names + invocation syntax. Target <4000 tokens (~16000 bytes).\n- Full mode: everything. Target <12000 tokens (~48000 bytes).\n- Measure with: `cargo run --release -- --robot robot-docs --brief | wc -c`\n\n## TDD Loop\nRED: Tests in src/main.rs or new src/cli/commands/robot_docs.rs:\n- test_robot_docs_has_quick_start: parse output JSON, assert quick_start.glab_equivalents array has >= 5 entries\n- test_robot_docs_brief_size: --brief output < 16000 bytes\n- test_robot_docs_full_size: full output < 48000 bytes\n- test_robot_docs_has_response_schemas: every command entry has response_schema key\n- test_robot_docs_commands_complete: assert all registered commands appear (issues, mrs, search, who, timeline, count, stats, sync, embed, doctor, health, ingest, generate-docs, show)\n\nGREEN: Add quick_start, response_schema, example_output to robot-docs output\n\nVERIFY:\n```bash\ncargo test robot_docs && cargo clippy --all-targets -- -D warnings\ncargo run --release -- --robot robot-docs | jq '.quick_start.glab_equivalents | length'\n# Should return >= 5\ncargo run --release -- --robot robot-docs --brief | wc -c\n# Should be < 16000\n```\n\n## Acceptance Criteria\n- [ ] robot-docs JSON has quick_start.glab_equivalents array with >= 5 entries\n- [ ] robot-docs JSON has quick_start.lore_exclusive array\n- [ ] Every command entry has response_schema showing the JSON shape\n- [ ] Every command entry has example_output with realistic truncated data\n- [ ] --brief output is under 16000 bytes (~4000 tokens)\n- [ ] Full output is under 48000 bytes (~12000 tokens)\n- [ ] An agent reading ONLY robot-docs can correctly invoke any lore command\n- [ ] cargo test passes with new robot_docs tests\n\n## Edge Cases\n- Commands with multiple output shapes (e.g., issues list vs issues detail via iid) need both schemas documented\n- --fields flag changes output shape -- document the effect in the response_schema\n- robot-docs output must be stable across versions (agents may cache it)\n- Version field should match Cargo.toml version\n\n## Files to Modify\n- src/main.rs fn handle_robot_docs() (~line 2069) — add quick_start section, response_schema, example_output\n- Consider extracting to src/cli/commands/robot_docs.rs if the function exceeds 200 lines","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-12T15:44:40.495479Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:01.043915Z","closed_at":"2026-02-12T16:49:01.043832Z","close_reason":"Robot-docs enhanced with quick_start (glab equivalents, lore exclusives, read/write split) and example_output for issues/mrs/search/who","compaction_level":0,"original_size":0,"labels":["cli","cli-imp","robot-mode"],"dependencies":[{"issue_id":"bd-91j1","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-9av","title":"[CP1] gi sync-status enhancement","description":"Enhance sync-status from CP0 stub to show issue cursors.\n\n## Changes to src/cli/commands/sync_status.rs\n\nUpdate the existing stub to show:\n- Last run timestamp and duration\n- Cursor positions per project (issues resource_type)\n- Entity counts (issues, discussions, notes)\n\n## Output Format\nLast sync: 2026-01-25 10:30:00 (succeeded, 45s)\n\nCursors:\n group/project-one\n issues: 2026-01-25T10:25:00Z (gitlab_id: 12345678)\n\nCounts:\n Issues: 1,234\n Discussions: 5,678\n Notes: 23,456 (4,567 system)\n\nFiles: src/cli/commands/sync_status.rs\nDone when: Shows cursor positions and counts after ingestion","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:58:27.246825Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.968507Z","closed_at":"2026-01-25T17:02:01.968507Z","deleted_at":"2026-01-25T17:02:01.968503Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-9dd","title":"Implement 'lore trace' command with human and robot output","description":"## Background\n\nThe trace command is Gate 5's capstone CLI. It answers 'Why was this code introduced?' by building file -> MR -> issue -> discussion chains.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.3.\n\n## Codebase Context\n\n- CLI pattern: same as file-history (Commands enum, handler in main.rs)\n- trace.rs (bd-2n4): run_trace() returns TraceResult with chains\n- Path parsing: support 'src/foo.rs:45' syntax (line number for future Tier 2)\n- merge_requests.merged_at exists (migration 006) — use COALESCE(merged_at, updated_at) for ordering\n\n## Approach\n\n### 1. TraceArgs (`src/cli/mod.rs`):\n```rust\n#[derive(Parser)]\npub struct TraceArgs {\n pub path: String, // supports :line suffix\n #[arg(short = 'p', long)] pub project: Option,\n #[arg(long)] pub discussions: bool,\n #[arg(long = \"no-follow-renames\")] pub no_follow_renames: bool,\n #[arg(short = 'n', long = \"limit\", default_value = \"20\")] pub limit: usize,\n}\n```\n\n### 2. Path parsing:\n```rust\nfn parse_trace_path(input: &str) -> (String, Option) {\n if let Some((path, line)) = input.rsplit_once(':') {\n if let Ok(n) = line.parse::() { return (path.to_string(), Some(n)); }\n }\n (input.to_string(), None)\n}\n```\nIf line present: warn 'Line-level tracing requires Tier 2. Showing file-level results.'\n\n### 3. Human output shows chains with MR -> issue -> discussion context\n\n### 4. Robot JSON:\n```json\n{\"ok\": true, \"data\": {\"path\": \"...\", \"resolved_paths\": [...], \"trace_chains\": [...]}, \"meta\": {\"tier\": \"api_only\", \"line_requested\": null}}\n```\n\n## Acceptance Criteria\n\n- [ ] `lore trace src/foo.rs` with human output\n- [ ] `lore --robot trace src/foo.rs` with JSON\n- [ ] :line suffix parses and emits Tier 2 warning\n- [ ] -p, --discussions, --no-follow-renames, -n all work\n- [ ] Rename-aware via resolve_rename_chain\n- [ ] meta.tier = 'api_only'\n- [ ] Added to VALID_COMMANDS and robot-docs\n- [ ] `cargo check --all-targets` passes\n\n## Files\n\n- `src/cli/mod.rs` (TraceArgs + Commands::Trace)\n- `src/cli/commands/trace.rs` (NEW)\n- `src/cli/commands/mod.rs` (re-export)\n- `src/main.rs` (handler + VALID_COMMANDS + robot-docs)\n\n## TDD Loop\n\nRED:\n- `test_parse_trace_path_simple` - \"src/foo.rs\" -> (path, None)\n- `test_parse_trace_path_with_line` - \"src/foo.rs:42\" -> (path, Some(42))\n- `test_parse_trace_path_windows` - \"C:/foo.rs\" -> (path, None) — don't misparse drive letter\n\nGREEN: Implement CLI wiring and handlers.\n\nVERIFY: `cargo check --all-targets`\n\n## Edge Cases\n\n- Windows paths: don't misparse C: as line number\n- No MR data: friendly message with suggestion to sync\n- Very deep rename chain: bounded by resolve_rename_chain","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:32.788530Z","created_by":"tayloreernisse","updated_at":"2026-02-19T13:46:36.973805Z","closed_at":"2026-02-19T13:46:36.973541Z","close_reason":"Implementation complete: trace CLI command with human + robot output, all tests passing","compaction_level":0,"original_size":0,"labels":["cli","gate-5","phase-b"],"dependencies":[{"issue_id":"bd-9dd","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-9dd","depends_on_id":"bd-2n4","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} -{"id":"bd-9lbr","title":"lore explain: auto-generate issue/MR narrative","description":"## Background\nGiven an issue or MR, auto-generate a structured narrative of what happened: who was involved, what decisions were made, what changed, and what is unresolved. Template-based v1 (no LLM dependency), deterministic and reproducible.\n\n## Current Infrastructure (Verified 2026-02-12)\n- show.rs: IssueDetail (line 69) and MrDetail (line 14) — entity detail with discussions\n- timeline.rs: 5-stage pipeline SHIPPED — chronological event reconstruction\n- notes table: 282K rows with body, author, created_at, is_system, discussion_id\n- discussions table: links notes to parent entity (noteable_type, noteable_id), has resolved flag\n- resource_state_events table: state changes with created_at, user_username (src/core/events_db.rs)\n- resource_label_events table: label add/remove with created_at, user_username\n- entity_references table (src/core/references.rs): cross-references between entities (closing MRs, related issues). Column names: `source_entity_type`, `source_entity_id`, `target_entity_type`, `target_entity_id`, `target_project_path`, `target_entity_iid`, `reference_type`, `source_method`\n\n## Approach\nNew command: `lore explain issues N` / `lore explain mrs N`\n\n### Data Assembly (reuse existing internals as library calls)\n1. Entity detail: reuse show.rs query logic for IssueDetail/MrDetail\n2. Timeline events: reuse timeline pipeline with entity-scoped seed\n3. Discussion notes:\n```sql\nSELECT n.id, n.body, n.author_username, n.created_at\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = ? AND d.noteable_id = ?\n AND n.is_system = 0\nORDER BY n.created_at\n```\n4. Cross-references:\n```sql\nSELECT target_entity_type, target_entity_id, target_project_path,\n target_entity_iid, reference_type, source_method\nFROM entity_references\nWHERE (source_entity_type = ?1 AND source_entity_id = ?2)\nUNION ALL\nSELECT source_entity_type, source_entity_id, NULL,\n NULL, reference_type, source_method\nFROM entity_references\nWHERE (target_entity_type = ?1 AND target_entity_id = ?2)\n```\n\n### Key Decisions Heuristic\nNotes from assignees/author that follow state or label changes within 1 hour:\n```rust\nstruct StateOrLabelEvent {\n created_at: i64, // ms epoch\n user: String,\n description: String, // e.g. \"state: opened -> closed\" or \"label: +bug\"\n}\n\nfn extract_key_decisions(\n state_events: &[ResourceStateEvent],\n label_events: &[ResourceLabelEvent],\n notes: &[Note],\n) -> Vec {\n // Merge both event types into a unified chronological list\n let mut events: Vec = Vec::new();\n for e in state_events {\n events.push(StateOrLabelEvent {\n created_at: e.created_at,\n user: e.user_username.clone(),\n description: format!(\"state: {} -> {}\", e.from_state.as_deref().unwrap_or(\"?\"), e.to_state),\n });\n }\n for e in label_events {\n let action = if e.action == \"add\" { \"+\" } else { \"-\" };\n events.push(StateOrLabelEvent {\n created_at: e.created_at,\n user: e.user_username.clone(),\n description: format!(\"label: {}{}\", action, e.label_name.as_deref().unwrap_or(\"?\")),\n });\n }\n events.sort_by_key(|e| e.created_at);\n\n let mut decisions = Vec::new();\n let one_hour_ms: i64 = 60 * 60 * 1000;\n\n for event in &events {\n // Find notes by same actor within 60 min after the event\n for note in notes {\n if note.author_username == event.user\n && note.created_at >= event.created_at\n && note.created_at <= event.created_at + one_hour_ms\n {\n decisions.push(KeyDecision {\n timestamp: event.created_at,\n actor: event.user.clone(),\n action: event.description.clone(),\n context_note: truncate(¬e.body, 500),\n });\n break; // one note per event\n }\n }\n }\n decisions.truncate(10); // Cap at 10 key decisions\n decisions\n}\n```\n\n### Narrative Sections\n1. **Header**: title, author, opened date, state, assignees, labels, status_name\n2. **Description excerpt**: first 500 chars of description (or full if shorter)\n3. **Key decisions**: notes correlated with state/label changes (heuristic above)\n4. **Activity summary**: counts of state changes, label changes, notes, time range\n5. **Open threads**: discussions WHERE resolved = false\n6. **Related entities**: closing MRs (with state), related issues from entity_references\n7. **Timeline excerpt**: first 20 events from timeline pipeline\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": {\n \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\", \"state\": \"opened\",\n \"author\": \"teernisse\", \"assignees\": [\"teernisse\"],\n \"labels\": [\"customer:BNSF\"], \"created_at\": \"...\", \"updated_at\": \"...\",\n \"url\": \"...\", \"status_name\": \"In progress\"\n },\n \"description_excerpt\": \"First 500 chars of description...\",\n \"key_decisions\": [{\n \"timestamp\": \"2026-01-15T...\",\n \"actor\": \"teernisse\",\n \"action\": \"state: opened -> in_progress\",\n \"context_note\": \"Starting work on the BNSF throw time integration...\"\n }],\n \"activity\": {\n \"state_changes\": 3, \"label_changes\": 5, \"notes\": 42,\n \"first_event\": \"2026-01-10T...\", \"last_event\": \"2026-02-12T...\"\n },\n \"open_threads\": [{\n \"discussion_id\": \"abc123\",\n \"started_by\": \"cseiber\",\n \"started_at\": \"2026-02-01T...\",\n \"note_count\": 5,\n \"last_note_at\": \"2026-02-10T...\"\n }],\n \"related\": {\n \"closing_mrs\": [{ \"iid\": 200, \"title\": \"...\", \"state\": \"merged\" }],\n \"related_issues\": [{ \"iid\": 3800, \"title\": \"Rail Break Card\", \"relation\": \"related\" }]\n },\n \"timeline_excerpt\": [{ \"timestamp\": \"...\", \"event_type\": \"...\", \"actor\": \"...\", \"summary\": \"...\" }]\n },\n \"meta\": { \"elapsed_ms\": 350 }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nExplain {\n /// Entity type: \"issues\" or \"mrs\"\n entity_type: String,\n /// Entity IID\n iid: i64,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/explain.rs:\n- test_explain_issue_basic: insert issue + notes + state events, run explain, assert all sections present (entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt)\n- test_explain_key_decision_heuristic: insert state change event + note by same author within 30 min, assert note appears in key_decisions\n- test_explain_key_decision_ignores_unrelated_notes: insert note by different author, assert it does NOT appear in key_decisions\n- test_explain_open_threads: insert 2 discussions (1 resolved, 1 unresolved), assert only unresolved in open_threads\n- test_explain_no_notes: issue with zero notes produces header + description + empty sections\n- test_explain_mr: insert MR with merged_at, assert entity includes type=\"merge_request\"\n- test_explain_activity_counts: insert 3 state events + 2 label events + 10 notes, assert counts match\n\nGREEN: Implement explain command with section assembly\n\nVERIFY:\n```bash\ncargo test explain:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J explain issues 3864 | jq '.data | keys'\n# Should include: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt\n```\n\n## Acceptance Criteria\n- [ ] lore explain issues N produces structured output for any synced issue\n- [ ] lore explain mrs N produces structured output for any synced MR\n- [ ] Robot mode returns all 7 sections\n- [ ] Human mode renders readable narrative with headers and indentation\n- [ ] Key decisions heuristic: captures notes within 60 min of state/label changes by same actor\n- [ ] Works fully offline (no API calls, no LLM)\n- [ ] Performance: <500ms for issue with 50 notes\n- [ ] Command registered in main.rs and robot-docs\n- [ ] key_decisions capped at 10, timeline_excerpt capped at 20 events\n\n## Edge Cases\n- Issue with empty description: description_excerpt = \"(no description)\"\n- Issue with 500+ notes: timeline_excerpt capped at 20, key_decisions capped at 10\n- Issue not found in local DB: exit code 17 with suggestion to sync\n- Ambiguous project: exit code 18 with suggestion to use -p flag\n- MR with no review activity: activity section shows zeros\n- Cross-project references: show as unresolved with project path hint\n- Notes that are pure code blocks: include in key_decisions if correlated with events (they may contain implementation decisions)\n- ResourceStateEvent/ResourceLabelEvent field names: check src/core/events_db.rs for exact struct definitions before implementing\n\n## Dependency Context\n- **bd-2g50 (data gaps)**: BLOCKER. Provides `closed_at` field on IssueDetail for the header section. Without it, explain can still show state=\"closed\" but won't have the exact close timestamp.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/explain.rs\n- src/cli/commands/mod.rs (add pub mod explain; re-export)\n- src/main.rs (register Explain subcommand in Commands enum, add handle_explain fn)\n- Reuse: show.rs queries, timeline pipeline, notes/discussions/resource_events queries from src/core/events_db.rs","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:46:41.386454Z","created_by":"tayloreernisse","updated_at":"2026-02-19T14:22:13.501482Z","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-9lbr","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-9lbr","depends_on_id":"bd-2g50","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} +{"id":"bd-9lbr","title":"lore explain: auto-generate issue/MR narrative","description":"## Background\nGiven an issue or MR, auto-generate a structured narrative of what happened: who was involved, what decisions were made, what changed, and what is unresolved. Template-based v1 (no LLM dependency), deterministic and reproducible.\n\n## Current Infrastructure (Verified 2026-02-12)\n- show.rs: IssueDetail (line 69) and MrDetail (line 14) — entity detail with discussions\n- timeline.rs: 5-stage pipeline SHIPPED — chronological event reconstruction\n- notes table: 282K rows with body, author, created_at, is_system, discussion_id\n- discussions table: links notes to parent entity (noteable_type, noteable_id), has resolved flag\n- resource_state_events table: state changes with created_at, user_username (src/core/events_db.rs)\n- resource_label_events table: label add/remove with created_at, user_username\n- entity_references table (src/core/references.rs): cross-references between entities (closing MRs, related issues). Column names: `source_entity_type`, `source_entity_id`, `target_entity_type`, `target_entity_id`, `target_project_path`, `target_entity_iid`, `reference_type`, `source_method`\n\n## Approach\nNew command: `lore explain issues N` / `lore explain mrs N`\n\n### Data Assembly (reuse existing internals as library calls)\n1. Entity detail: reuse show.rs query logic for IssueDetail/MrDetail\n2. Timeline events: reuse timeline pipeline with entity-scoped seed\n3. Discussion notes:\n```sql\nSELECT n.id, n.body, n.author_username, n.created_at\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = ? AND d.noteable_id = ?\n AND n.is_system = 0\nORDER BY n.created_at\n```\n4. Cross-references:\n```sql\nSELECT target_entity_type, target_entity_id, target_project_path,\n target_entity_iid, reference_type, source_method\nFROM entity_references\nWHERE (source_entity_type = ?1 AND source_entity_id = ?2)\nUNION ALL\nSELECT source_entity_type, source_entity_id, NULL,\n NULL, reference_type, source_method\nFROM entity_references\nWHERE (target_entity_type = ?1 AND target_entity_id = ?2)\n```\n\n### Key Decisions Heuristic\nNotes from assignees/author that follow state or label changes within 1 hour:\n```rust\nstruct StateOrLabelEvent {\n created_at: i64, // ms epoch\n user: String,\n description: String, // e.g. \"state: opened -> closed\" or \"label: +bug\"\n}\n\nfn extract_key_decisions(\n state_events: &[ResourceStateEvent],\n label_events: &[ResourceLabelEvent],\n notes: &[Note],\n) -> Vec {\n // Merge both event types into a unified chronological list\n let mut events: Vec = Vec::new();\n for e in state_events {\n events.push(StateOrLabelEvent {\n created_at: e.created_at,\n user: e.user_username.clone(),\n description: format!(\"state: {} -> {}\", e.from_state.as_deref().unwrap_or(\"?\"), e.to_state),\n });\n }\n for e in label_events {\n let action = if e.action == \"add\" { \"+\" } else { \"-\" };\n events.push(StateOrLabelEvent {\n created_at: e.created_at,\n user: e.user_username.clone(),\n description: format!(\"label: {}{}\", action, e.label_name.as_deref().unwrap_or(\"?\")),\n });\n }\n events.sort_by_key(|e| e.created_at);\n\n let mut decisions = Vec::new();\n let one_hour_ms: i64 = 60 * 60 * 1000;\n\n for event in &events {\n // Find notes by same actor within 60 min after the event\n for note in notes {\n if note.author_username == event.user\n && note.created_at >= event.created_at\n && note.created_at <= event.created_at + one_hour_ms\n {\n decisions.push(KeyDecision {\n timestamp: event.created_at,\n actor: event.user.clone(),\n action: event.description.clone(),\n context_note: truncate(¬e.body, 500),\n });\n break; // one note per event\n }\n }\n }\n decisions.truncate(10); // Cap at 10 key decisions\n decisions\n}\n```\n\n### Narrative Sections\n1. **Header**: title, author, opened date, state, assignees, labels, status_name\n2. **Description excerpt**: first 500 chars of description (or full if shorter)\n3. **Key decisions**: notes correlated with state/label changes (heuristic above)\n4. **Activity summary**: counts of state changes, label changes, notes, time range\n5. **Open threads**: discussions WHERE resolved = false\n6. **Related entities**: closing MRs (with state), related issues from entity_references\n7. **Timeline excerpt**: first 20 events from timeline pipeline\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": {\n \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\", \"state\": \"opened\",\n \"author\": \"teernisse\", \"assignees\": [\"teernisse\"],\n \"labels\": [\"customer:BNSF\"], \"created_at\": \"...\", \"updated_at\": \"...\",\n \"url\": \"...\", \"status_name\": \"In progress\"\n },\n \"description_excerpt\": \"First 500 chars of description...\",\n \"key_decisions\": [{\n \"timestamp\": \"2026-01-15T...\",\n \"actor\": \"teernisse\",\n \"action\": \"state: opened -> in_progress\",\n \"context_note\": \"Starting work on the BNSF throw time integration...\"\n }],\n \"activity\": {\n \"state_changes\": 3, \"label_changes\": 5, \"notes\": 42,\n \"first_event\": \"2026-01-10T...\", \"last_event\": \"2026-02-12T...\"\n },\n \"open_threads\": [{\n \"discussion_id\": \"abc123\",\n \"started_by\": \"cseiber\",\n \"started_at\": \"2026-02-01T...\",\n \"note_count\": 5,\n \"last_note_at\": \"2026-02-10T...\"\n }],\n \"related\": {\n \"closing_mrs\": [{ \"iid\": 200, \"title\": \"...\", \"state\": \"merged\" }],\n \"related_issues\": [{ \"iid\": 3800, \"title\": \"Rail Break Card\", \"relation\": \"related\" }]\n },\n \"timeline_excerpt\": [{ \"timestamp\": \"...\", \"event_type\": \"...\", \"actor\": \"...\", \"summary\": \"...\" }]\n },\n \"meta\": { \"elapsed_ms\": 350 }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nExplain {\n /// Entity type: \"issues\" or \"mrs\"\n entity_type: String,\n /// Entity IID\n iid: i64,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/explain.rs:\n- test_explain_issue_basic: insert issue + notes + state events, run explain, assert all sections present (entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt)\n- test_explain_key_decision_heuristic: insert state change event + note by same author within 30 min, assert note appears in key_decisions\n- test_explain_key_decision_ignores_unrelated_notes: insert note by different author, assert it does NOT appear in key_decisions\n- test_explain_open_threads: insert 2 discussions (1 resolved, 1 unresolved), assert only unresolved in open_threads\n- test_explain_no_notes: issue with zero notes produces header + description + empty sections\n- test_explain_mr: insert MR with merged_at, assert entity includes type=\"merge_request\"\n- test_explain_activity_counts: insert 3 state events + 2 label events + 10 notes, assert counts match\n\nGREEN: Implement explain command with section assembly\n\nVERIFY:\n```bash\ncargo test explain:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J explain issues 3864 | jq '.data | keys'\n# Should include: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt\n```\n\n## Acceptance Criteria\n- [ ] lore explain issues N produces structured output for any synced issue\n- [ ] lore explain mrs N produces structured output for any synced MR\n- [ ] Robot mode returns all 7 sections\n- [ ] Human mode renders readable narrative with headers and indentation\n- [ ] Key decisions heuristic: captures notes within 60 min of state/label changes by same actor\n- [ ] Works fully offline (no API calls, no LLM)\n- [ ] Performance: <500ms for issue with 50 notes\n- [ ] Command registered in main.rs and robot-docs\n- [ ] key_decisions capped at 10, timeline_excerpt capped at 20 events\n\n## Edge Cases\n- Issue with empty description: description_excerpt = \"(no description)\"\n- Issue with 500+ notes: timeline_excerpt capped at 20, key_decisions capped at 10\n- Issue not found in local DB: exit code 17 with suggestion to sync\n- Ambiguous project: exit code 18 with suggestion to use -p flag\n- MR with no review activity: activity section shows zeros\n- Cross-project references: show as unresolved with project path hint\n- Notes that are pure code blocks: include in key_decisions if correlated with events (they may contain implementation decisions)\n- ResourceStateEvent/ResourceLabelEvent field names: check src/core/events_db.rs for exact struct definitions before implementing\n\n## Dependency Context\n- **bd-2g50 (data gaps)**: BLOCKER. Provides `closed_at` field on IssueDetail for the header section. Without it, explain can still show state=\"closed\" but won't have the exact close timestamp.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/explain.rs\n- src/cli/commands/mod.rs (add pub mod explain; re-export)\n- src/main.rs (register Explain subcommand in Commands enum, add handle_explain fn)\n- Reuse: show.rs queries, timeline pipeline, notes/discussions/resource_events queries from src/core/events_db.rs","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:46:41.386454Z","created_by":"tayloreernisse","updated_at":"2026-02-19T14:38:41.304718Z","closed_at":"2026-02-19T14:38:41.304645Z","close_reason":"Implemented lore explain: auto-generated narrative for issues/MRs. 7 unit tests, CLI wired, robot-docs, autocorrect.","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-9lbr","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-9lbr","depends_on_id":"bd-2g50","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-9wl5","title":"NOTE-2G: Parent metadata change propagation to note documents","description":"## Background\nNote documents inherit labels and title from parent issue/MR. When parent metadata changes, note documents become stale. The existing pipeline already marks discussion documents dirty on parent changes — note documents need the same treatment.\n\n## Approach\nFind where ingestion detects parent entity changes and marks discussion documents dirty. The dirty marking for discussions happens in:\n- src/ingestion/discussions.rs line 127: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)\n- src/ingestion/mr_discussions.rs line 162 and 362: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)\n\nThese fire when a discussion is upserted (which happens when parent entity is re-ingested). For note documents, we need to additionally mark all non-system notes of that discussion as dirty:\n\nAfter each mark_dirty_tx for Discussion, add:\n // Mark child note documents dirty (they inherit parent metadata)\n let note_ids: Vec = tx.prepare(\"SELECT id FROM notes WHERE discussion_id = ? AND is_system = 0\")?\n .query_map([local_discussion_id], |r| r.get(0))?\n .collect::, _>>()?;\n for note_id in note_ids {\n dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, note_id)?;\n }\n\nAlternative (more efficient, set-based):\n INSERT INTO dirty_sources (source_type, source_id, queued_at)\n SELECT 'note', n.id, ?1\n FROM notes n\n WHERE n.discussion_id = ?2 AND n.is_system = 0\n ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0\n\nUse the set-based approach for better performance with large discussions.\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (add note dirty marking after line 127)\n- MODIFY: src/ingestion/mr_discussions.rs (add note dirty marking after lines 162 and 362)\n\n## TDD Anchor\nRED: test_parent_title_change_marks_notes_dirty — change issue title, re-ingest discussions, assert note documents appear in dirty_sources.\nGREEN: Add set-based INSERT INTO dirty_sources after discussion dirty marking.\nVERIFY: cargo test parent_title_change_marks_notes -- --nocapture\nTests: test_parent_label_change_marks_notes_dirty (modify issue labels, re-ingest, check dirty queue)\n\n## Acceptance Criteria\n- [ ] Discussion upsert for issue marks child non-system note documents dirty\n- [ ] Discussion upsert for MR marks child non-system note documents dirty (both call sites)\n- [ ] Only non-system notes marked dirty (is_system = 0 filter)\n- [ ] Set-based SQL (not per-note loop) for performance\n- [ ] Both tests pass\n\n## Dependency Context\n- Depends on NOTE-2D (bd-2ezb): dirty tracking infrastructure for notes must exist (dirty_sources accepts source_type='note', regenerator handles it)\n\n## Edge Cases\n- Discussion with 0 non-system notes: set-based INSERT is a no-op\n- Discussion with 100+ notes: set-based approach handles efficiently in one SQL statement\n- Concurrent discussion ingestion: ON CONFLICT DO UPDATE handles race safely","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:40.292874Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.717576Z","closed_at":"2026-02-12T18:13:15.717528Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]} {"id":"bd-a6yb","title":"Implement responsive breakpoints for all TUI screens","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T04:52:55.561576Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:10:12.531731Z","closed_at":"2026-02-19T05:10:12.531557Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-a6yb","depends_on_id":"bd-3t6r","type":"blocks","created_at":"2026-02-19T04:53:02.566163Z","created_by":"tayloreernisse"}]} {"id":"bd-am7","title":"Implement embedding pipeline with chunking","description":"## Background\nThe embedding pipeline takes documents, chunks them (paragraph-boundary splitting with overlap), sends chunks to Ollama for embedding via async HTTP, and stores vectors in sqlite-vec + metadata. It uses keyset pagination, concurrent HTTP requests via FuturesUnordered, per-batch transactions, and dimension validation.\n\n## Approach\nCreate \\`src/embedding/pipeline.rs\\` per PRD Section 4.4. **The pipeline is async.**\n\n**Constants (per PRD):**\n```rust\nconst BATCH_SIZE: usize = 32; // texts per Ollama API call\nconst DB_PAGE_SIZE: usize = 500; // keyset pagination page size\nconst EXPECTED_DIMS: usize = 768; // nomic-embed-text dimensions\nconst CHUNK_MAX_CHARS: usize = 32_000; // max chars per chunk\nconst CHUNK_OVERLAP_CHARS: usize = 500; // overlap between chunks\n```\n\n**Core async function:**\n```rust\npub async fn embed_documents(\n conn: &Connection,\n client: &OllamaClient,\n selection: EmbedSelection,\n concurrency: usize, // max in-flight HTTP requests\n progress_callback: Option>,\n) -> Result\n```\n\n**EmbedSelection:** Pending | RetryFailed\n**EmbedResult:** { embedded, failed, skipped }\n\n**Algorithm (per PRD):**\n1. count_pending_documents(conn, selection) for progress total\n2. Keyset pagination loop: find_pending_documents(conn, DB_PAGE_SIZE, last_id, selection)\n3. For each page:\n a. Begin transaction\n b. For each doc: clear_document_embeddings(&tx, doc.id), split_into_chunks(&doc.content)\n c. Build ChunkWork items with doc_hash + chunk_hash\n d. Commit clearing transaction\n4. Batch ChunkWork texts into Ollama calls (BATCH_SIZE=32)\n5. Use **FuturesUnordered** for concurrent HTTP, cap at \\`concurrency\\`\n6. collect_writes() in per-batch transactions: validate dims (768), store LE bytes, write metadata\n7. On error: record_embedding_error per chunk (not abort)\n8. Advance keyset cursor\n\n**ChunkWork struct:**\n```rust\nstruct ChunkWork {\n doc_id: i64,\n chunk_index: usize,\n doc_hash: String, // SHA-256 of FULL document (staleness detection)\n chunk_hash: String, // SHA-256 of THIS chunk (provenance)\n text: String,\n}\n```\n\n**Splitting:** split_into_chunks(content) -> Vec<(usize, String)>\n- Documents <= CHUNK_MAX_CHARS: single chunk (index 0)\n- Longer: split at paragraph boundaries (\\\\n\\\\n), fallback to sentence/word, with CHUNK_OVERLAP_CHARS overlap\n\n**Storage:** embeddings as raw LE bytes, rowid = encode_rowid(doc_id, chunk_idx)\n**Staleness detection:** uses document_hash (not chunk_hash) because it's document-level\n\nAlso create \\`src/embedding/change_detector.rs\\` (referenced in PRD module structure):\n```rust\npub fn detect_embedding_changes(conn: &Connection) -> Result>;\n```\n\n## Acceptance Criteria\n- [ ] Pipeline is async (uses FuturesUnordered for concurrent HTTP)\n- [ ] concurrency parameter caps in-flight HTTP requests\n- [ ] progress_callback reports (processed, total)\n- [ ] New documents embedded, changed re-embedded, unchanged skipped\n- [ ] clear_document_embeddings before re-embedding (range delete vec0 + metadata)\n- [ ] Chunking at paragraph boundaries with 500-char overlap\n- [ ] Short documents (<32k chars) produce exactly 1 chunk\n- [ ] Embeddings stored as raw LE bytes in vec0\n- [ ] Rowids encoded via encode_rowid(doc_id, chunk_index)\n- [ ] Dimension validation: 768 floats per embedding (mismatch -> record error, not store)\n- [ ] Per-batch transactions for writes\n- [ ] Errors recorded in embedding_metadata per chunk (last_error, attempt_count)\n- [ ] Keyset pagination (d.id > last_id, not OFFSET)\n- [ ] Pending detection uses document_hash (not chunk_hash)\n- [ ] \\`cargo build\\` succeeds\n\n## Files\n- \\`src/embedding/pipeline.rs\\` — new file (async)\n- \\`src/embedding/change_detector.rs\\` — new file\n- \\`src/embedding/mod.rs\\` — add \\`pub mod pipeline; pub mod change_detector;\\` + re-exports\n\n## TDD Loop\nRED: Unit tests for chunking:\n- \\`test_short_document_single_chunk\\` — <32k produces [(0, full_content)]\n- \\`test_long_document_multiple_chunks\\` — >32k splits at paragraph boundaries\n- \\`test_chunk_overlap\\` — adjacent chunks share 500-char overlap\n- \\`test_no_paragraph_boundary\\` — falls back to char boundary\nIntegration tests need Ollama or mock.\nGREEN: Implement split_into_chunks, embed_documents (async)\nVERIFY: \\`cargo test pipeline\\`\n\n## Edge Cases\n- Empty document content_text: skip (don't embed)\n- No paragraph boundaries: split at CHUNK_MAX_CHARS with overlap\n- Ollama error for one batch: record error per chunk, continue with next batch\n- Dimension mismatch (model returns 512 instead of 768): record error, don't store corrupt data\n- Document deleted between pagination and embedding: skip gracefully","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:34.093701Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:58:58.908585Z","closed_at":"2026-01-30T17:58:58.908525Z","close_reason":"Implemented embedding pipeline: chunking at paragraph boundaries with 500-char overlap, change detector (keyset pagination, hash-based staleness), async embed via Ollama with batch processing, dimension validation, per-chunk error recording, LE byte vector storage. 7 chunking tests pass. 289 total tests.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-am7","depends_on_id":"bd-1y8","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-am7","depends_on_id":"bd-2ac","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-am7","depends_on_id":"bd-335","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} diff --git a/crates/lore-tui/src/action/bootstrap.rs b/crates/lore-tui/src/action/bootstrap.rs index 900cb6e..9d0515d 100644 --- a/crates/lore-tui/src/action/bootstrap.rs +++ b/crates/lore-tui/src/action/bootstrap.rs @@ -28,8 +28,8 @@ pub fn check_schema_version(conn: &Connection, minimum: i32) -> SchemaCheck { return SchemaCheck::NoDB; } - // Read the current version. - match conn.query_row("SELECT version FROM schema_version LIMIT 1", [], |r| { + // Read the highest version (one row per migration). + match conn.query_row("SELECT MAX(version) FROM schema_version", [], |r| { r.get::<_, i32>(0) }) { Ok(version) if version >= minimum => SchemaCheck::Compatible { version }, @@ -65,7 +65,7 @@ pub fn check_data_readiness(conn: &Connection) -> Result { .unwrap_or(false); let schema_version = conn - .query_row("SELECT version FROM schema_version LIMIT 1", [], |r| { + .query_row("SELECT MAX(version) FROM schema_version", [], |r| { r.get::<_, i32>(0) }) .unwrap_or(0); @@ -247,6 +247,24 @@ mod tests { assert!(matches!(result, SchemaCheck::NoDB)); } + #[test] + fn test_schema_preflight_multiple_migration_rows() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER, applied_at INTEGER, description TEXT); + INSERT INTO schema_version VALUES (1, 0, 'Initial'); + INSERT INTO schema_version VALUES (2, 0, 'Second'); + INSERT INTO schema_version VALUES (27, 0, 'Latest');", + ) + .unwrap(); + + let result = check_schema_version(&conn, 20); + assert!( + matches!(result, SchemaCheck::Compatible { version: 27 }), + "should use MAX(version), not first row: {result:?}" + ); + } + #[test] fn test_check_data_readiness_empty() { let conn = Connection::open_in_memory().unwrap(); diff --git a/crates/lore-tui/src/lib.rs b/crates/lore-tui/src/lib.rs index 1b44e2d..e0b82c0 100644 --- a/crates/lore-tui/src/lib.rs +++ b/crates/lore-tui/src/lib.rs @@ -71,6 +71,8 @@ pub struct LaunchOptions { /// 2. **Data readiness** — check whether the database has any entity data. /// If empty, start on the Bootstrap screen; otherwise start on Dashboard. pub fn launch_tui(options: LaunchOptions) -> Result<()> { + let _options = options; // remaining fields (fresh, ascii, etc.) consumed in later phases + // 1. Resolve database path. let db_path = lore::core::paths::get_db_path(None); if !db_path.exists() { @@ -84,7 +86,7 @@ pub fn launch_tui(options: LaunchOptions) -> Result<()> { // 2. Open DB and run schema preflight. let db = db::DbManager::open(&db_path) .with_context(|| format!("opening database at {}", db_path.display()))?; - db.with_reader(|conn| schema_preflight(conn))?; + db.with_reader(schema_preflight)?; // 3. Check data readiness — bootstrap screen if empty. let start_on_bootstrap = db.with_reader(|conn| { diff --git a/src/cli/autocorrect.rs b/src/cli/autocorrect.rs index df9c482..27af1d0 100644 --- a/src/cli/autocorrect.rs +++ b/src/cli/autocorrect.rs @@ -289,6 +289,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[ ("show", &["--project"]), ("reset", &["--yes"]), ("related", &["--limit", "--project"]), + ("explain", &["--project"]), ]; /// Valid values for enum-like flags, used for post-clap error enhancement. diff --git a/src/cli/commands/explain.rs b/src/cli/commands/explain.rs index a665a8b..9aa737c 100644 --- a/src/cli/commands/explain.rs +++ b/src/cli/commands/explain.rs @@ -8,8 +8,9 @@ use rusqlite::Connection; use serde::Serialize; use crate::core::config::Config; -use crate::core::db::{create_connection, get_db_path}; +use crate::core::db::create_connection; use crate::core::error::Result; +use crate::core::paths::get_db_path; use super::show::{ClosingMrRef, RelatedIssueRef}; @@ -384,20 +385,15 @@ struct IssueRow { status_name: Option, } -fn find_issue_row( - conn: &Connection, - iid: i64, - project_filter: Option<&str>, -) -> Result { +fn find_issue_row(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { let (sql, params): (&str, Vec>) = match project_filter { Some(project) => { let project_id = resolve_project(conn, project)?; ( "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, i.created_at, i.updated_at, i.web_url, - ws.status_name + i.status_name FROM issues i - LEFT JOIN work_item_status ws ON ws.issue_id = i.id WHERE i.iid = ?1 AND i.project_id = ?2", vec![ Box::new(iid) as Box, @@ -408,9 +404,8 @@ fn find_issue_row( None => ( "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, i.created_at, i.updated_at, i.web_url, - ws.status_name + i.status_name FROM issues i - LEFT JOIN work_item_status ws ON ws.issue_id = i.id WHERE i.iid = ?1", vec![Box::new(iid) as Box], ), @@ -433,9 +428,9 @@ fn find_issue_row( }) }) .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => { - crate::core::error::LoreError::NotFound(format!("Issue #{iid} not found in local database. Run 'lore sync' first.")) - } + rusqlite::Error::QueryReturnedNoRows => crate::core::error::LoreError::NotFound(format!( + "Issue #{iid} not found in local database. Run 'lore sync' first." + )), other => crate::core::error::LoreError::Database(other), }) } @@ -453,11 +448,7 @@ struct MrRow { web_url: Option, } -fn find_mr_row( - conn: &Connection, - iid: i64, - project_filter: Option<&str>, -) -> Result { +fn find_mr_row(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { let (sql, params): (&str, Vec>) = match project_filter { Some(project) => { let project_id = resolve_project(conn, project)?; @@ -497,9 +488,9 @@ fn find_mr_row( }) }) .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => { - crate::core::error::LoreError::NotFound(format!("MR !{iid} not found in local database. Run 'lore sync' first.")) - } + rusqlite::Error::QueryReturnedNoRows => crate::core::error::LoreError::NotFound(format!( + "MR !{iid} not found in local database. Run 'lore sync' first." + )), other => crate::core::error::LoreError::Database(other), }) } @@ -518,15 +509,16 @@ fn resolve_project(conn: &Connection, project: &str) -> Result { ); id.map_err(|_| { - crate::core::error::LoreError::NotFound(format!( - "Project matching '{project}' not found." - )) + crate::core::error::LoreError::NotFound(format!("Project matching '{project}' not found.")) }) } fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result> { let mut stmt = conn.prepare_cached( - "SELECT label FROM issue_labels WHERE issue_id = ? ORDER BY label", + "SELECT l.name FROM issue_labels il + JOIN labels l ON l.id = il.label_id + WHERE il.issue_id = ? + ORDER BY l.name", )?; let labels: Vec = stmt .query_map([issue_id], |row| row.get(0))? @@ -546,7 +538,10 @@ fn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result> fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result> { let mut stmt = conn.prepare_cached( - "SELECT label FROM mr_labels WHERE merge_request_id = ? ORDER BY label", + "SELECT l.name FROM mr_labels ml + JOIN labels l ON l.id = ml.label_id + WHERE ml.merge_request_id = ? + ORDER BY l.name", )?; let labels: Vec = stmt .query_map([mr_id], |row| row.get(0))? @@ -671,7 +666,7 @@ fn query_open_threads( }; let sql = format!( - "SELECT d.gitlab_id, + "SELECT d.gitlab_discussion_id, MIN(n.author_username) AS started_by, MIN(n.created_at) AS started_at, COUNT(n.id) AS note_count, @@ -806,9 +801,12 @@ fn truncate_str(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() } else { - // Find a char boundary near max_len - let truncated = &s[..s.floor_char_boundary(max_len)]; - format!("{truncated}...") + // Find a char boundary at or before max_len + let mut end = max_len; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &s[..end]) } } @@ -826,7 +824,7 @@ pub fn print_explain_json(response: &ExplainResponse, elapsed_ms: u64) { } pub fn print_explain_human(response: &ExplainResponse) { - use crate::core::time::format_ms_relative; + use crate::core::time::ms_to_iso; // Header let entity = &response.entity; @@ -841,7 +839,7 @@ pub fn print_explain_human(response: &ExplainResponse) { "State: {} | Author: {} | Created: {}", entity.state, entity.author, - format_ms_relative(entity.created_at), + ms_to_iso(entity.created_at), ); if !entity.assignees.is_empty() { @@ -864,12 +862,7 @@ pub fn print_explain_human(response: &ExplainResponse) { if !response.key_decisions.is_empty() { println!("--- Key Decisions ({}) ---", response.key_decisions.len()); for d in &response.key_decisions { - println!( - " {} | {} | {}", - format_ms_relative(d.timestamp), - d.actor, - d.action, - ); + println!(" {} | {} | {}", ms_to_iso(d.timestamp), d.actor, d.action,); // Show first line of the context note let first_line = d.context_note.lines().next().unwrap_or(""); if !first_line.is_empty() { @@ -886,11 +879,7 @@ pub fn print_explain_human(response: &ExplainResponse) { a.state_changes, a.label_changes, a.notes, ); if let (Some(first), Some(last)) = (a.first_event, a.last_event) { - println!( - " Span: {} to {}", - format_ms_relative(first), - format_ms_relative(last), - ); + println!(" Span: {} to {}", ms_to_iso(first), ms_to_iso(last),); } println!(); @@ -903,7 +892,7 @@ pub fn print_explain_human(response: &ExplainResponse) { t.discussion_id, t.started_by, t.note_count, - format_ms_relative(t.last_note_at), + ms_to_iso(t.last_note_at), ); } println!(); @@ -935,7 +924,7 @@ pub fn print_explain_human(response: &ExplainResponse) { for e in &response.timeline_excerpt { println!( " {} | {} | {} | {}", - format_ms_relative(e.timestamp), + ms_to_iso(e.timestamp), e.event_type, e.actor, e.summary.lines().next().unwrap_or(""), @@ -971,8 +960,8 @@ mod tests { fn insert_issue(conn: &Connection, id: i64, iid: i64, title: &str) { conn.execute( - "INSERT INTO issues (id, project_id, gitlab_id, iid, title, state, author_username, created_at, updated_at) - VALUES (?1, 1, ?1, ?2, ?3, 'opened', 'alice', 1000000, 2000000)", + "INSERT INTO issues (id, project_id, gitlab_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) + VALUES (?1, 1, ?1, ?2, ?3, 'opened', 'alice', 1000000, 2000000, 9999999)", rusqlite::params![id, iid, title], ) .unwrap(); @@ -980,37 +969,60 @@ mod tests { fn insert_mr(conn: &Connection, id: i64, iid: i64, title: &str) { conn.execute( - "INSERT INTO merge_requests (id, project_id, gitlab_id, iid, title, state, author_username, source_branch, target_branch, created_at, updated_at) - VALUES (?1, 1, ?1, ?2, ?3, 'merged', 'bob', 'feat', 'main', 1000000, 2000000)", + "INSERT INTO merge_requests (id, project_id, gitlab_id, iid, title, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at) + VALUES (?1, 1, ?1, ?2, ?3, 'merged', 'bob', 'feat', 'main', 1000000, 2000000, 9999999)", rusqlite::params![id, iid, title], ) .unwrap(); } - fn insert_discussion(conn: &Connection, id: i64, gitlab_id: &str, noteable_type: &str, entity_id: i64, resolved: bool) { + fn insert_discussion( + conn: &Connection, + id: i64, + gitlab_id: &str, + noteable_type: &str, + entity_id: i64, + resolved: bool, + ) { let (issue_id, mr_id) = match noteable_type { "Issue" => (Some(entity_id), None), "MergeRequest" => (None, Some(entity_id)), _ => panic!("bad noteable_type"), }; conn.execute( - "INSERT INTO discussions (id, gitlab_id, project_id, noteable_type, issue_id, merge_request_id, resolved, individual_note) - VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, 0)", + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, noteable_type, issue_id, merge_request_id, resolved, individual_note, last_seen_at) + VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, 0, 9999999)", rusqlite::params![id, gitlab_id, noteable_type, issue_id, mr_id, resolved], ) .unwrap(); } - fn insert_note(conn: &Connection, id: i64, discussion_id: i64, author: &str, body: &str, created_at: i64, is_system: bool) { + fn insert_note( + conn: &Connection, + id: i64, + discussion_id: i64, + author: &str, + body: &str, + created_at: i64, + is_system: bool, + ) { conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, author_username, body, created_at, updated_at, is_system, noteable_type) - VALUES (?1, ?1, ?2, ?3, ?4, ?5, ?5, ?6, 'Issue')", + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, is_system, last_seen_at) + VALUES (?1, ?1, ?2, 1, ?3, ?4, ?5, ?5, ?6, 9999999)", rusqlite::params![id, discussion_id, author, body, created_at, is_system], ) .unwrap(); } - fn insert_state_event(conn: &Connection, id: i64, issue_id: Option, mr_id: Option, state: &str, actor: &str, created_at: i64) { + fn insert_state_event( + conn: &Connection, + id: i64, + issue_id: Option, + mr_id: Option, + state: &str, + actor: &str, + created_at: i64, + ) { conn.execute( "INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at) VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6)", @@ -1019,7 +1031,17 @@ mod tests { .unwrap(); } - fn insert_label_event(conn: &Connection, id: i64, issue_id: Option, mr_id: Option, action: &str, label: &str, actor: &str, created_at: i64) { + #[allow(clippy::too_many_arguments)] + fn insert_label_event( + conn: &Connection, + id: i64, + issue_id: Option, + mr_id: Option, + action: &str, + label: &str, + actor: &str, + created_at: i64, + ) { conn.execute( "INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at) VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6, ?7)", @@ -1064,7 +1086,15 @@ mod tests { // Note by alice at t=1000000 + 30min (within 60min window) insert_discussion(&conn, 1, "disc-1", "Issue", 1, true); - insert_note(&conn, 1, 1, "alice", "Fixed by reverting the config change", 1_000_000 + 30 * 60 * 1000, false); + insert_note( + &conn, + 1, + 1, + "alice", + "Fixed by reverting the config change", + 1_000_000 + 30 * 60 * 1000, + false, + ); let response = explain_issue(&conn, 42, Some("group/repo")).unwrap(); @@ -1085,7 +1115,15 @@ mod tests { // Note by BOB (different actor) within window insert_discussion(&conn, 1, "disc-1", "Issue", 1, true); - insert_note(&conn, 1, 1, "bob", "Why was this closed?", 1_000_000 + 10 * 60 * 1000, false); + insert_note( + &conn, + 1, + 1, + "bob", + "Why was this closed?", + 1_000_000 + 10 * 60 * 1000, + false, + ); let response = explain_issue(&conn, 42, Some("group/repo")).unwrap(); @@ -1105,7 +1143,15 @@ mod tests { // Unresolved discussion insert_discussion(&conn, 2, "disc-open", "Issue", 1, false); insert_note(&conn, 2, 2, "bob", "What about edge cases?", 1600000, false); - insert_note(&conn, 3, 2, "alice", "Good point, investigating", 1700000, false); + insert_note( + &conn, + 3, + 2, + "alice", + "Good point, investigating", + 1700000, + false, + ); let response = explain_issue(&conn, 42, Some("group/repo")).unwrap(); diff --git a/src/cli/commands/who/format.rs b/src/cli/commands/who/format.rs new file mode 100644 index 0000000..02f87a5 --- /dev/null +++ b/src/cli/commands/who/format.rs @@ -0,0 +1,696 @@ +use serde::Serialize; + +use crate::cli::WhoArgs; +use crate::cli::render::{self, Icons, Theme}; +use crate::cli::robot::RobotMeta; +use crate::core::time::ms_to_iso; +use crate::core::who_types::{ + ActiveResult, ExpertResult, OverlapResult, ReviewsResult, WhoResult, WorkloadResult, +}; + +use super::queries::format_overlap_role; +use super::{WhoRun, WhoResolvedInput}; + +// ─── 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::>(), + }) +} diff --git a/src/cli/commands/who/queries.rs b/src/cli/commands/who/queries.rs new file mode 100644 index 0000000..b1e999b --- /dev/null +++ b/src/cli/commands/who/queries.rs @@ -0,0 +1,1431 @@ +use rusqlite::Connection; +use std::collections::{HashMap, HashSet}; + +use crate::core::config::ScoringConfig; +use crate::core::error::Result; +use crate::core::path_resolver::{PathQuery, build_path_query}; +use crate::core::who_types::{ + ActiveDiscussion, ActiveResult, Expert, ExpertMrDetail, ExpertResult, OverlapResult, + OverlapUser, ReviewCategory, ReviewsResult, ScoreComponents, WorkloadDiscussion, WorkloadIssue, + WorkloadMr, WorkloadResult, +}; + +use super::scoring::half_life_decay; + +/// Maximum MR references to retain per user in output (shared across modes). +const MAX_MR_REFS_PER_USER: usize = 50; + +// ─── 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". +pub(super) 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) => "-", + } +} diff --git a/src/cli/commands/who/scoring.rs b/src/cli/commands/who/scoring.rs new file mode 100644 index 0000000..78e2c37 --- /dev/null +++ b/src/cli/commands/who/scoring.rs @@ -0,0 +1,20 @@ +// ─── 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) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6b9f7c8..c1715ea 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -250,6 +250,20 @@ pub enum Commands { #[command(visible_alias = "similar")] Related(RelatedArgs), + /// Auto-generate a structured narrative for an issue or MR + Explain { + /// Entity type: "issues" or "mrs" + #[arg(value_parser = ["issues", "issue", "mrs", "mr"])] + entity_type: String, + + /// Entity IID + iid: i64, + + /// Scope to project (fuzzy match) + #[arg(short, long)] + project: Option, + }, + /// Detect discussion divergence from original intent Drift { /// Entity type (currently only "issues" supported) diff --git a/src/main.rs b/src/main.rs index 1dda1a2..e1bf79d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,19 +14,20 @@ use lore::cli::commands::{ open_issue_in_browser, open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_doctor_results, print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json, print_embed, print_embed_json, print_event_count, - print_event_count_json, print_file_history, print_file_history_json, print_generate_docs, - print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues, - print_list_issues_json, print_list_mrs, print_list_mrs_json, print_list_notes, - print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_reference_count, - print_reference_count_json, print_related, print_related_json, print_search_results, - print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr, - print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json, - print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta, - print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test, - run_count, run_count_events, run_count_references, run_doctor, run_drift, run_embed, - run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, - run_list_mrs, run_related, run_search, run_show_issue, run_show_mr, run_stats, run_sync, - run_sync_status, run_timeline, run_tui, run_who, + print_event_count_json, print_explain_human, print_explain_json, print_file_history, + print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary, + print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs, + print_list_mrs_json, print_list_notes, print_list_notes_csv, print_list_notes_json, + print_list_notes_jsonl, print_reference_count, print_reference_count_json, print_related, + print_related_json, print_search_results, print_search_results_json, print_show_issue, + print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json, + print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline, + print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json, + query_notes, run_auth_test, run_count, run_count_events, run_count_references, run_doctor, + run_drift, run_embed, run_explain, run_file_history, run_generate_docs, run_ingest, + run_ingest_dry_run, run_init, run_list_issues, run_list_mrs, run_related, run_search, + run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, run_tui, + run_who, }; use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme}; use lore::cli::robot::{RobotMeta, strip_schemas}; @@ -210,6 +211,17 @@ async fn main() { handle_related(cli.config.as_deref(), args, robot_mode).await } Some(Commands::Tui(args)) => run_tui(&args, robot_mode), + Some(Commands::Explain { + entity_type, + iid, + project, + }) => handle_explain( + cli.config.as_deref(), + &entity_type, + iid, + project.as_deref(), + robot_mode, + ), Some(Commands::Drift { entity_type, iid, @@ -734,6 +746,7 @@ fn suggest_similar_command(invalid: &str) -> String { ("who", "who"), ("notes", "notes"), ("note", "notes"), + ("explain", "explain"), ("drift", "drift"), ("file-history", "file-history"), ("trace", "trace"), @@ -2814,6 +2827,17 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box", "", "-p/--project "], + "example": "lore --robot explain issues 42", + "notes": "Template-based (no LLM), deterministic. Sections: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt.", + "response_schema": { + "ok": "bool", + "data": "ExplainResponse with entity{type,iid,title,state,author,assignees,labels,created_at,updated_at,url?,status_name?}, description_excerpt, key_decisions[{timestamp,actor,action,context_note}], activity{state_changes,label_changes,notes,first_event?,last_event?}, open_threads[{discussion_id,started_by,started_at,note_count,last_note_at}], related{closing_mrs[],related_issues[]}, timeline_excerpt[{timestamp,event_type,actor,summary}]", + "meta": {"elapsed_ms": "int"} + } + }, "drift": { "description": "Detect discussion divergence from original issue intent", "flags": ["", "", "--threshold <0.0-1.0>", "-p/--project "], @@ -2873,6 +2897,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box MR -> issue -> discussion decision chain", "related: Semantic similarity discovery via vector embeddings", + "explain: Auto-generated narrative for any issue or MR (template-based, no LLM)", "drift: Discussion divergence detection from original intent", "notes: Rich note listing with author, type, resolution, path, and discussion filters", "stats: Database statistics with document/note/discussion counts", @@ -3073,6 +3098,26 @@ fn handle_who( Ok(()) } +fn handle_explain( + config_override: Option<&str>, + entity_type: &str, + iid: i64, + project: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let response = run_explain(&config, entity_type, iid, project)?; + let elapsed_ms = start.elapsed().as_millis() as u64; + + if robot_mode { + print_explain_json(&response, elapsed_ms); + } else { + print_explain_human(&response); + } + Ok(()) +} + async fn handle_drift( config_override: Option<&str>, entity_type: &str,