diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 867a43e..3e814ca 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -20,7 +20,7 @@ {"id":"bd-18qs","title":"Implement entity table + filter bar widgets","description":"## Background\nThe entity table and filter bar are shared widgets used by Issue List, MR List, and potentially Search results. The entity table supports sortable columns with responsive width allocation. The filter bar provides a typed DSL for filtering with inline diagnostics.\n\n## Approach\nEntity Table (view/common/entity_table.rs):\n- EntityTable widget: generic over row type\n- TableRow trait: fn cells(&self) -> Vec, fn sort_key(&self, col: usize) -> Ordering\n- Column definitions: name, min_width, flex_weight, alignment, sort_field\n- Responsive column fitting: hide low-priority columns as terminal narrows\n- Keyboard: j/k scroll, J/K page scroll, Tab cycle sort column, Enter select, g+g top, G bottom\n- Visual: alternating row colors, selected row highlight, sort indicator arrow\n\nFilter Bar (view/common/filter_bar.rs):\n- FilterBar widget wrapping ftui TextInput\n- DSL parsing (crate filter_dsl.rs): quoted values (\"in progress\"), negation prefix (-closed), field:value syntax (author:taylor, state:opened, label:bug), free-text search\n- Inline diagnostics: unknown field names highlighted, cursor position for error\n- Applied filter chips shown as tags below the input\n\nFilter DSL (filter_dsl.rs):\n- parse_filter_tokens(input: &str) -> Vec\n- FilterToken enum: FieldValue{field, value}, Negation{field, value}, FreeText(String), QuotedValue(String)\n- Validation: known fields per entity type (issues: state, author, assignee, label, milestone, status; MRs: state, author, reviewer, target_branch, source_branch, label, draft)\n\n## Acceptance Criteria\n- [ ] EntityTable renders with responsive column widths\n- [ ] Columns hide gracefully when terminal is too narrow\n- [ ] j/k scrolls, Enter selects, Tab cycles sort column\n- [ ] Sort indicator (arrow) shows on active sort column\n- [ ] FilterBar captures text input and parses DSL tokens\n- [ ] Quoted values preserved as single token\n- [ ] Negation prefix (-closed) creates exclusion filter\n- [ ] field:value syntax maps to typed filter fields\n- [ ] Unknown field names highlighted as error\n- [ ] Filter chips rendered below input bar\n\n## Files\n- CREATE: crates/lore-tui/src/view/common/entity_table.rs\n- CREATE: crates/lore-tui/src/view/common/filter_bar.rs\n- CREATE: crates/lore-tui/src/filter_dsl.rs\n\n## TDD Anchor\nRED: Write test_parse_filter_basic in filter_dsl.rs that parses \"state:opened author:taylor\" and asserts two FieldValue tokens.\nGREEN: Implement parse_filter_tokens with field:value splitting.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_parse_filter\n\nAdditional tests:\n- test_parse_quoted_value: \"in progress\" -> single QuotedValue token\n- test_parse_negation: -closed -> Negation token\n- test_parse_mixed: state:opened \"bug fix\" -wontfix -> 3 tokens of correct types\n- test_column_hiding: EntityTable with 5 columns hides lowest priority at 60 cols\n\n## Edge Cases\n- Filter DSL must handle Unicode in values (CJK issue titles)\n- Empty filter string should show all results (no-op)\n- Very long filter strings must not overflow the input area\n- Tab cycling sort must wrap around (last column -> first)\n- Column widths must respect min_width even when terminal is very narrow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:58:07.586225Z","created_by":"tayloreernisse","updated_at":"2026-02-18T19:18:07.275204Z","closed_at":"2026-02-18T19:18:07.275087Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-18qs","depends_on_id":"bd-1cl9","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-18qs","depends_on_id":"bd-6pmy","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-18t","title":"Implement discussion truncation logic","description":"## Background\nDiscussion threads can contain dozens of notes spanning thousands of characters. The truncation module ensures discussion documents stay within a 32k character limit (suitable for embedding chunking) by dropping middle notes while preserving first and last notes for context. A separate hard safety cap of 2MB applies to ALL document types for pathological content (pasted logs, base64 blobs). Issue/MR documents are NOT truncated by the discussion logic — only the hard cap applies.\n\n## Approach\nCreate `src/documents/truncation.rs` per PRD Section 2.3:\n\n```rust\npub const MAX_DISCUSSION_CHARS: usize = 32_000;\npub const MAX_DOCUMENT_CHARS_HARD: usize = 2_000_000;\n\npub struct NoteContent {\n pub author: String,\n pub date: String,\n pub body: String,\n}\n\npub struct TruncationResult {\n pub content: String,\n pub is_truncated: bool,\n pub reason: Option,\n}\n\npub enum TruncationReason {\n TokenLimitMiddleDrop,\n SingleNoteOversized,\n FirstLastOversized,\n HardCapOversized,\n}\n```\n\n**Core functions:**\n- `truncate_discussion(notes: &[NoteContent], max_chars: usize) -> TruncationResult`\n- `truncate_utf8(s: &str, max_bytes: usize) -> &str` (shared with fts.rs)\n- `truncate_hard_cap(content: &str) -> TruncationResult` (for any doc type)\n\n**Algorithm for truncate_discussion:**\n1. Format all notes as `@author (date):\\nbody\\n\\n`\n2. If total <= max_chars: return as-is\n3. If single note: truncate at UTF-8 boundary, append `[truncated]`, reason = SingleNoteOversized\n4. Binary search: find max N where first N notes + last 1 note + marker fit within max_chars\n5. If first + last > max_chars: keep only first (truncated), reason = FirstLastOversized\n6. Otherwise: first N + marker + last M, reason = TokenLimitMiddleDrop\n\n**Marker format:** `\\n\\n[... N notes omitted for length ...]\\n\\n`\n\n## Acceptance Criteria\n- [ ] Discussion with total < 32k chars returns untruncated\n- [ ] Discussion > 32k chars: middle notes dropped, first + last preserved\n- [ ] Truncation marker shows correct count of omitted notes\n- [ ] Single note > 32k chars: truncated at UTF-8-safe boundary with `[truncated]` appended\n- [ ] First + last note > 32k: only first note kept (truncated if needed)\n- [ ] Hard cap (2MB) truncates any document type at UTF-8-safe boundary\n- [ ] `truncate_utf8` never panics on multi-byte codepoints (emoji, CJK, accented chars)\n- [ ] `TruncationReason::as_str()` returns DB-compatible strings matching CHECK constraint\n\n## Files\n- `src/documents/truncation.rs` — new file\n- `src/documents/mod.rs` — add `pub use truncation::{truncate_discussion, truncate_hard_cap, TruncationResult, NoteContent};`\n\n## TDD Loop\nRED: Tests in `#[cfg(test)] mod tests`:\n- `test_no_truncation_under_limit` — 3 short notes, all fit\n- `test_middle_notes_dropped` — 10 notes totaling > 32k, first+last preserved\n- `test_single_note_oversized` — one note of 50k chars, truncated safely\n- `test_first_last_oversized` — first=20k, last=20k, only first kept\n- `test_one_note_total` — single note under limit: no truncation\n- `test_utf8_boundary_safety` — content with emoji/CJK at truncation point\n- `test_hard_cap` — 3MB content truncated to 2MB\n- `test_marker_count_correct` — marker says \"[... 5 notes omitted ...]\" when 5 dropped\nGREEN: Implement truncation logic\nVERIFY: `cargo test truncation`\n\n## Edge Cases\n- Empty notes list: return empty content, not truncated\n- All notes are empty strings: total = 0, no truncation\n- Note body contains only multi-byte characters: truncate_utf8 walks backward to find safe boundary\n- Note body with trailing newlines: formatted output should not have excessive blank lines","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:25:45.597167Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:21:32.256569Z","closed_at":"2026-01-30T17:21:32.256507Z","close_reason":"Completed: truncate_discussion, truncate_hard_cap, truncate_utf8, TruncationReason with as_str(), 12 tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-18t","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-18yh","title":"NOTE-2C: Note document extractor function","description":"## Background\nEach non-system note becomes a searchable document in the FTS/embedding pipeline. Follows the pattern of extract_issue_document() (line 85), extract_mr_document() (line 186), extract_discussion_document() (line 302) in src/documents/extractor.rs.\n\n## Approach\nAdd pub fn extract_note_document(conn: &Connection, note_id: i64) -> Result> to src/documents/extractor.rs:\n\n1. Fetch note with JOIN to discussions and projects:\n SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system, n.created_at, n.updated_at, n.position_new_path, n.position_new_line, n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by, d.noteable_type, d.issue_id, d.merge_request_id, p.path_with_namespace, p.id as project_id\n FROM notes n\n JOIN discussions d ON n.discussion_id = d.id\n JOIN projects p ON n.project_id = p.id\n WHERE n.id = ?\n\n2. Return None for: system notes (is_system = 1), not found, orphaned discussions (no parent issue/MR)\n\n3. Fetch parent entity (Issue or MR) — get iid, title, web_url, labels:\n For issues: SELECT iid, title, web_url FROM issues WHERE id = ?\n For MRs: SELECT iid, title, web_url FROM merge_requests WHERE id = ?\n Labels: SELECT label_name FROM issue_labels/mr_labels WHERE issue_id/mr_id = ?\n (Same pattern as extract_discussion_document lines 332-401)\n\n4. Build paths: BTreeSet from position_old_path + position_new_path (filter None values)\n\n5. Build URL: parent_web_url + \"#note_{gitlab_id}\"\n\n6. Format content with structured key-value header:\n [[Note]]\n source_type: note\n note_gitlab_id: {gitlab_id}\n project: {path_with_namespace}\n parent_type: {Issue|MergeRequest}\n parent_iid: {iid}\n parent_title: {title}\n note_type: {DiffNote|DiscussionNote|...}\n author: @{author}\n created_at: {iso8601}\n resolved: {true|false} (only if resolvable)\n path: {position_new_path}:{line} (only if DiffNote with path)\n labels: {comma-separated parent labels}\n url: {url}\n\n --- Body ---\n\n {body}\n\n7. Title: \"Note by @{author} on {Issue|MR} #{iid}: {parent_title}\"\n\n8. Compute hashes: content_hash via compute_content_hash() (line 66), labels_hash via compute_list_hash(), paths_hash via compute_list_hash(). Apply truncate_hard_cap() (imported from truncation.rs at line 9).\n\n9. Return DocumentData (struct defined at line 47) with: source_type: SourceType::Note, source_id: note_id, project_id, author_username, labels, paths (as Vec), labels_hash, paths_hash, created_at, updated_at, url, title, content_text (from hard_cap), content_hash, is_truncated, truncated_reason.\n\n## Files\n- MODIFY: src/documents/extractor.rs (add extract_note_document after extract_discussion_document, ~line 500)\n- MODIFY: src/documents/mod.rs (add extract_note_document to pub use exports, line 12 area)\n\n## TDD Anchor\nRED: test_note_document_basic_format — insert project, issue, discussion, note; extract; assert content contains [[Note]], author, parent reference.\nGREEN: Implement extract_note_document with SQL JOIN and content formatting.\nVERIFY: cargo test note_document_basic_format -- --nocapture\nTests: test_note_document_diffnote_with_path, test_note_document_inherits_parent_labels, test_note_document_mr_parent, test_note_document_system_note_returns_none, test_note_document_not_found, test_note_document_orphaned_discussion, test_note_document_hash_deterministic, test_note_document_empty_body, test_note_document_null_body\n\n## Acceptance Criteria\n- [ ] extract_note_document returns Some(DocumentData) for non-system notes\n- [ ] Returns None for system notes, not-found, orphaned discussions\n- [ ] Content includes structured [[Note]] header with all parent context fields\n- [ ] DiffNote includes file path and line info in content header\n- [ ] Labels inherited from parent issue/MR\n- [ ] URL format: parent_url#note_{gitlab_id}\n- [ ] Title format: \"Note by @{author} on {Issue|MR} #{iid}: {parent_title}\"\n- [ ] Hash is deterministic across calls (same input = same hash)\n- [ ] Empty/null body handled gracefully (use empty string)\n- [ ] truncate_hard_cap applied to content\n- [ ] All 10 tests pass\n\n## Dependency Context\n- Depends on NOTE-2B (bd-ef0u): SourceType::Note variant must exist to construct DocumentData\n\n## Edge Cases\n- NULL body: use empty string \"\" — not all notes have body text\n- Orphaned discussion: parent issue/MR deleted but discussion remains — return None\n- Very long note body: truncate_hard_cap handles this (2MB limit)\n- Note with no position data: skip path line in content header\n- Note on MR vs Issue: different label table (mr_labels vs issue_labels)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:01.802842Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:23.928224Z","closed_at":"2026-02-12T18:13:23.928173Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-18yh","depends_on_id":"bd-2ezb","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-18yh","depends_on_id":"bd-3cjp","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} -{"id":"bd-1au9","title":"Audit and improve test coverage across ingestion module","description":"During code reorganization, discovered that ingestion/issues.rs has only 4 tests covering passes_cursor_filter, while 10 production functions (~400 lines) are untested:\n\nUNTESTED FUNCTIONS in ingestion/issues.rs:\n- ingest_issues() - main async pipeline with cursor-based pagination, shutdown handling\n- process_single_issue() - transforms GitLab issue, wraps in transaction\n- process_issue_in_transaction() - DB upsert with ON CONFLICT, label/assignee/milestone association, dirty tracking\n- upsert_label_tx() - label upsert with INSERT OR IGNORE + created count tracking\n- link_issue_label_tx() - issue-label junction table insert\n- upsert_milestone_tx() - milestone upsert with RETURNING id\n- get_sync_cursor() - reads sync_cursors table for incremental sync\n- update_sync_cursor() - writes sync cursor with tie-breaker ID\n- get_issues_needing_discussion_sync() - identifies issues needing discussion refresh\n- parse_timestamp() - RFC3339 parsing with error wrapping\n\nLIKELY SIMILAR GAPS in sibling files:\n- ingestion/merge_requests.rs (479 lines) - parallel structure to issues.rs\n- ingestion/discussions.rs (469 lines prod code) - discussion upsert pipeline\n- ingestion/mr_discussions.rs (738 lines prod before tests) - MR discussion pipeline\n- ingestion/orchestrator.rs (1703 lines) - full pipeline orchestration\n\nThe ingestion module handles the most critical data path (GitLab API -> SQLite) yet relies primarily on integration-level orchestrator tests rather than unit tests for individual functions.\n\nPRIORITY AREAS:\n1. DB upsert logic with ON CONFLICT handling (data correctness)\n2. Cursor-based pagination (incremental sync correctness)\n3. Label/milestone/assignee association (relational integrity)\n4. Dirty tracker marking after upserts (document pipeline triggering)\n5. Discussion sync queue population (cascading sync correctness)\n6. Error handling paths (invalid timestamps, missing data)\n\nAPPROACH: Use in-memory SQLite (create_connection(Path::new(\":memory:\")) + run_migrations) for unit tests. See existing patterns in core/db_tests.rs and documents/regenerator_tests.rs.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-13T00:53:15.302370Z","created_by":"tayloreernisse","updated_at":"2026-02-13T00:53:15.305167Z","compaction_level":0,"original_size":0,"labels":["testing"]} +{"id":"bd-1au9","title":"Audit and improve test coverage across ingestion module","description":"During code reorganization, discovered that ingestion/issues.rs has only 4 tests covering passes_cursor_filter, while 10 production functions (~400 lines) are untested:\n\nUNTESTED FUNCTIONS in ingestion/issues.rs:\n- ingest_issues() - main async pipeline with cursor-based pagination, shutdown handling\n- process_single_issue() - transforms GitLab issue, wraps in transaction\n- process_issue_in_transaction() - DB upsert with ON CONFLICT, label/assignee/milestone association, dirty tracking\n- upsert_label_tx() - label upsert with INSERT OR IGNORE + created count tracking\n- link_issue_label_tx() - issue-label junction table insert\n- upsert_milestone_tx() - milestone upsert with RETURNING id\n- get_sync_cursor() - reads sync_cursors table for incremental sync\n- update_sync_cursor() - writes sync cursor with tie-breaker ID\n- get_issues_needing_discussion_sync() - identifies issues needing discussion refresh\n- parse_timestamp() - RFC3339 parsing with error wrapping\n\nLIKELY SIMILAR GAPS in sibling files:\n- ingestion/merge_requests.rs (479 lines) - parallel structure to issues.rs\n- ingestion/discussions.rs (469 lines prod code) - discussion upsert pipeline\n- ingestion/mr_discussions.rs (738 lines prod before tests) - MR discussion pipeline\n- ingestion/orchestrator.rs (1703 lines) - full pipeline orchestration\n\nThe ingestion module handles the most critical data path (GitLab API -> SQLite) yet relies primarily on integration-level orchestrator tests rather than unit tests for individual functions.\n\nPRIORITY AREAS:\n1. DB upsert logic with ON CONFLICT handling (data correctness)\n2. Cursor-based pagination (incremental sync correctness)\n3. Label/milestone/assignee association (relational integrity)\n4. Dirty tracker marking after upserts (document pipeline triggering)\n5. Discussion sync queue population (cascading sync correctness)\n6. Error handling paths (invalid timestamps, missing data)\n\nAPPROACH: Use in-memory SQLite (create_connection(Path::new(\":memory:\")) + run_migrations) for unit tests. See existing patterns in core/db_tests.rs and documents/regenerator_tests.rs.","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-13T00:53:15.302370Z","created_by":"tayloreernisse","updated_at":"2026-02-19T14:46:13.955975Z","compaction_level":0,"original_size":0,"labels":["testing"]} {"id":"bd-1b0n","title":"OBSERV: Print human-readable timing summary after interactive sync","description":"## Background\nInteractive users want a quick timing summary after sync completes. This is the human-readable equivalent of meta.stages in robot JSON. Gated behind IngestDisplay::show_text so it doesn't appear in -q, robot, or progress_only modes.\n\n## Approach\nAdd a function to format and print the timing summary, called from run_sync() after the pipeline completes:\n\n```rust\nfn print_timing_summary(stages: &[StageTiming], total_elapsed: Duration) {\n eprintln!();\n eprintln!(\"Sync complete in {:.1}s\", total_elapsed.as_secs_f64());\n for stage in stages {\n let dots = \".\".repeat(20_usize.saturating_sub(stage.name.len()));\n eprintln!(\n \" {} {} {:.1}s ({} items{})\",\n stage.name,\n dots,\n stage.elapsed_ms as f64 / 1000.0,\n stage.items_processed,\n if stage.errors > 0 { format!(\", {} errors\", stage.errors) } else { String::new() },\n );\n }\n}\n```\n\nCall in run_sync() (src/cli/commands/sync.rs), after pipeline and before return:\n```rust\nif display.show_text {\n let stages = metrics_handle.extract_timings();\n print_timing_summary(&stages, start.elapsed());\n}\n```\n\nOutput format per PRD Section 4.6.4:\n```\nSync complete in 45.2s\n Ingest issues .... 12.3s (150 items, 42 discussions)\n Ingest MRs ....... 18.9s (85 items, 1 error)\n Generate docs .... 8.5s (235 documents)\n Embed ............ 5.5s (1024 chunks)\n```\n\n## Acceptance Criteria\n- [ ] Interactive lore sync prints timing summary to stderr after completion\n- [ ] Summary shows total time and per-stage breakdown\n- [ ] lore -q sync does NOT print timing summary\n- [ ] Robot mode does NOT print timing summary (only JSON)\n- [ ] Error counts shown when non-zero\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync.rs (add print_timing_summary function, call after pipeline)\n\n## TDD Loop\nRED: test_timing_summary_format (capture stderr, verify format matches PRD example pattern)\nGREEN: Implement print_timing_summary, gate behind display.show_text\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Empty stages (e.g., sync with no projects configured): print \"Sync complete in 0.0s\" with no stage lines\n- Very fast stages (<1ms): show \"0.0s\" not scientific notation\n- Stage names with varying lengths: dot padding keeps alignment readable","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:32.109882Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:32:52.558314Z","closed_at":"2026-02-04T17:32:52.558264Z","close_reason":"Added print_timing_summary with per-stage breakdown (name, elapsed, items, errors, rate limits), nested sub-stage support, gated behind metrics Option","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1b0n","depends_on_id":"bd-1zj6","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1b0n","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-1b50","title":"Update existing tests for new ScoringConfig fields","description":"## Background\nThe existing test test_expert_scoring_weights_are_configurable (who.rs:3551-3574) constructs a ScoringConfig with only the original 3 fields. After bd-2w1p adds 8 new fields, this test will not compile without ..Default::default().\n\n## Approach\nFind the test at who.rs:3551-3574. The flipped config construction at line 3567:\n```rust\nlet flipped = ScoringConfig {\n author_weight: 5,\n reviewer_weight: 30,\n note_bonus: 1,\n};\n```\nChange to:\n```rust\nlet flipped = ScoringConfig {\n author_weight: 5,\n reviewer_weight: 30,\n note_bonus: 1,\n ..Default::default()\n};\n```\n\nAlso check default_scoring() helper at line 2451 — it calls ScoringConfig::default() which already works.\n\n### Important: Scope boundary\nThis bead ONLY handles ScoringConfig struct literal changes. The query_expert() function signature change (7 params -> 10 params) happens in bd-13q8 (Layer 3), which is responsible for updating all test callsites at that time.\n\n### Why existing assertions do not break:\nAll test data is inserted with now_ms(). With as_of_ms also at ~now_ms(), elapsed ~0ms, decay ~1.0. So integer-rounded scores are identical to the flat-weight model.\n\n## Acceptance Criteria\n- [ ] cargo test passes with zero assertion changes to existing test values\n- [ ] test_expert_scoring_weights_are_configurable compiles and passes\n- [ ] All other existing who tests pass unchanged\n- [ ] No new test code needed — only ..Default::default() additions\n- [ ] cargo check --all-targets clean\n\n## Files\n- MODIFY: src/cli/commands/who.rs (ScoringConfig literal at line 3567)\n\n## TDD Loop\nN/A — mechanical change, no new tests.\nVERIFY: cargo check --all-targets && cargo test -p lore -- test_expert_scoring_weights_are_configurable\n\n## Edge Cases\n- Search for ALL ScoringConfig { ... } literals in test module — there may be more than the one at line 3567\n- The default_scoring() helper at line 2451 uses ScoringConfig::default() — no change needed","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-09T17:00:45.084472Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.409277Z","closed_at":"2026-02-12T20:43:04.409239Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring","test"],"dependencies":[{"issue_id":"bd-1b50","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} {"id":"bd-1b6k","title":"Epic: TUI Phase 5.5 — Reliability Test Pack","description":"## Background\nPhase 5.5 is a comprehensive reliability test suite covering race conditions, stress tests, property-based testing, and deterministic clock verification. These tests ensure the TUI is robust under adverse conditions (rapid input, concurrent writes, resize storms, backpressure).\n\n## Acceptance Criteria\n- [ ] Stale response drop tests pass\n- [ ] Sync cancel/resume tests pass\n- [ ] SQLITE_BUSY retry tests pass\n- [ ] Resize storm + rapid keypress tests pass without panic\n- [ ] Property tests for navigation invariants pass\n- [ ] Performance benchmark fixtures (S/M/L tiers) pass SLOs\n- [ ] Event fuzz tests: 10k traces with zero invariant violations\n- [ ] Deterministic clock/render tests produce identical output\n- [ ] 30-minute soak test: no panic, no deadlock, memory growth < 5%\n- [ ] Concurrent pagination/write race tests: no duplicate/skipped rows\n- [ ] Query cancellation race tests: no cross-task bleed, no stuck loading","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:04:04.486702Z","created_by":"tayloreernisse","updated_at":"2026-02-19T12:49:54.360492Z","closed_at":"2026-02-19T12:49:54.360447Z","close_reason":"All 6 child beads closed: race conditions (16 tests), stress/fuzz (9), nav properties (10), perf benchmarks (14), soak (7), pagination races (7) = 63 reliability tests total","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-1b6k","depends_on_id":"bd-3t6r","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} @@ -58,7 +58,7 @@ {"id":"bd-1mf","title":"[CP1] gi sync-status enhancement","description":"Enhance sync-status from CP0 stub to show issue cursors.\n\nOutput:\n- Last run timestamp and duration\n- Cursor positions per project (issues resource_type)\n- Entity counts (issues, discussions, notes)\n\nFiles: src/cli/commands/sync-status.ts (update existing)\nDone when: Shows cursor positions and counts after ingestion","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:36.449088Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.157235Z","closed_at":"2026-01-25T15:21:35.157235Z","deleted_at":"2026-01-25T15:21:35.157232Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-1mju","title":"Vertical slice integration test + SLO verification","description":"## Background\nThe vertical slice gate validates that core screens work together end-to-end with real data flows and meet performance SLOs. This is a manual + automated verification pass.\n\n## Approach\nCreate integration tests in crates/lore-tui/tests/:\n- test_full_nav_flow: Dashboard -> press i -> IssueList loads -> press Enter -> IssueDetail loads -> press Esc -> back to IssueList with cursor preserved -> press H -> Dashboard\n- test_filter_requery: IssueList -> type filter -> verify re-query triggers and results update\n- test_stale_result_guard: rapidly navigate between screens, verify no stale data displayed\n- Performance benchmarks: run M-tier fixture, measure p95 nav latency, assert < 75ms\n- Stuck-input check: fuzz InputMode transitions, assert always recoverable via Esc or Ctrl+C\n- Cancel latency: start sync, cancel, measure time to acknowledgment, assert < 2s\n\n## Acceptance Criteria\n- [ ] Full nav flow test passes without panic\n- [ ] Filter re-query test shows updated results\n- [ ] No stale data displayed during rapid navigation\n- [ ] p95 nav latency < 75ms on M-tier fixtures\n- [ ] Zero stuck-input states across 1000 random key sequences\n- [ ] Sync cancel acknowledged p95 < 2s\n- [ ] All state preserved correctly on back-navigation\n\n## Files\n- CREATE: crates/lore-tui/tests/vertical_slice.rs\n\n## TDD Anchor\nRED: Write test_dashboard_to_issue_detail_roundtrip that navigates Dashboard -> IssueList -> IssueDetail -> Esc -> IssueList, asserts cursor position preserved.\nGREEN: Ensure all navigation and state preservation is wired up.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml vertical_slice\n\n## Edge Cases\n- Tests need FakeClock and synthetic DB fixtures (not real GitLab)\n- ftui test harness required for rendering tests without TTY\n- Performance benchmarks may vary by machine — use relative thresholds\n\n## Dependency Context\nRequires all Phase 2 screens: Dashboard, Issue List, Issue Detail, MR List, MR Detail.\nRequires NavigationStack, TaskSupervisor, DbManager from Phase 1.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:18.310264Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:06:21.021705Z","closed_at":"2026-02-18T21:06:21.021656Z","close_reason":"Vertical slice integration test complete: 11 tests covering nav flows, stale guards, input fuzz, bootstrap transition, render all screens, performance smoke","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-1mju","depends_on_id":"bd-3t1b","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1mju","depends_on_id":"bd-3ty8","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1mju","depends_on_id":"bd-8ab7","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-1n5","title":"[CP1] gi ingest --type=issues command","description":"CLI command to orchestrate issue ingestion.\n\nImplementation:\n1. Acquire app lock with heartbeat\n2. Create sync_run record (status='running')\n3. For each configured project:\n - Call ingestIssues()\n - For each ingested issue, call ingestIssueDiscussions()\n - Show progress (spinner or progress bar)\n4. Update sync_run (status='succeeded', metrics_json)\n5. Release lock\n\nFlags:\n- --type=issues (required)\n- --project=PATH (optional, filter to single project)\n- --force (override stale lock)\n\nOutput: Progress bar, then summary with counts\n\nFiles: src/cli/commands/ingest.ts\nTests: tests/integration/sync-runs.test.ts\nDone when: Full issue + discussion ingestion works end-to-end","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:20:05.114751Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.153598Z","closed_at":"2026-01-25T15:21:35.153598Z","deleted_at":"2026-01-25T15:21:35.153595Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-1n5q","title":"lore brief: situational awareness for topic/module/person","description":"## Background\nComposable capstone command. An agent says \"I am about to work on auth\" and gets everything in one call: open issues, active MRs, experts, recent activity, unresolved threads, related context. Replaces 5 separate lore calls with 1.\n\n## Input Modes\n1. Topic: `lore brief 'authentication'` — FTS search to find relevant entities, aggregate\n2. Path: `lore brief --path src/auth/` — who expert internals for path expertise\n3. Person: `lore brief --person teernisse` — who workload internals\n4. Entity: `lore brief issues 3864` — single entity focus with cross-references\n\n## Section Assembly Architecture\n\n### Reuse existing run_* functions (ship faster, recommended for v1)\nEach section calls existing CLI command functions and converts their output.\n\nIMPORTANT: All existing run_* functions take `&Config`, NOT `&Connection`. The Config contains the db_path and each function opens its own connection internally.\n\n```rust\n// In src/cli/commands/brief.rs\n\nuse crate::cli::commands::list::{run_list_issues, run_list_mrs, ListFilters, MrListFilters};\nuse crate::cli::commands::who::{run_who, WhoArgs, WhoMode};\nuse crate::core::config::Config;\n\npub async fn run_brief(config: &Config, args: BriefArgs) -> Result {\n let mut sections_computed = Vec::new();\n\n // 1. open_issues: reuse list.rs\n // Signature: pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result\n // Located at src/cli/commands/list.rs:268\n let open_issues = run_list_issues(config, ListFilters {\n state: Some(\"opened\".into()),\n limit: Some(5),\n project: args.project.clone(),\n // ... scope by topic/path/person based on mode\n ..Default::default()\n })?;\n sections_computed.push(\"open_issues\");\n\n // 2. active_mrs: reuse list.rs\n // Signature: pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result\n // Located at src/cli/commands/list.rs:476\n let active_mrs = run_list_mrs(config, MrListFilters {\n state: Some(\"opened\".into()),\n limit: Some(5),\n project: args.project.clone(),\n ..Default::default()\n })?;\n sections_computed.push(\"active_mrs\");\n\n // 3. experts: reuse who.rs\n // Signature: pub fn run_who(config: &Config, args: &WhoArgs) -> Result\n // Located at src/cli/commands/who.rs:276\n let experts = run_who(config, &WhoArgs {\n mode: WhoMode::Expert,\n path: args.path.clone(),\n limit: Some(3),\n ..Default::default()\n })?;\n sections_computed.push(\"experts\");\n\n // 4. recent_activity: reuse timeline internals\n // The timeline pipeline is 5-stage (SEED->HYDRATE->EXPAND->COLLECT->RENDER)\n // Types in src/core/timeline.rs, seed in src/core/timeline_seed.rs\n // ...etc\n}\n```\n\nNOTE: ListFilters and MrListFilters may not implement Default. Check before using `..Default::default()`. If they don't, derive it or construct all fields explicitly.\n\n### Concrete Function References (src/cli/commands/)\n| Module | Function | Signature | Line |\n|--------|----------|-----------|------|\n| list.rs | run_list_issues | `(config: &Config, filters: ListFilters) -> Result` | 268 |\n| list.rs | run_list_mrs | `(config: &Config, filters: MrListFilters) -> Result` | 476 |\n| who.rs | run_who | `(config: &Config, args: &WhoArgs) -> Result` | 276 |\n| search.rs | run_search | `(config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool) -> Result` | 61 |\n\nNOTE: run_search is currently synchronous (pub fn, not pub async fn). If bd-1ksf ships first, it becomes async. Brief should handle both cases — call `.await` if async, direct call if sync.\n\n### Section Details\n| Section | Source | Limit | Fallback |\n|---------|--------|-------|----------|\n| open_issues | list.rs with state=opened | 5 | empty array |\n| active_mrs | list.rs with state=opened | 5 | empty array |\n| experts | who.rs Expert mode | 3 | empty array (no path data) |\n| recent_activity | timeline pipeline | 10 events | empty array |\n| unresolved_threads | SQL: discussions WHERE resolved=false | 5 | empty array |\n| related | search_vector() via bd-8con | 5 | omit section (no embeddings) |\n| warnings | computed from dates/state | all | empty array |\n\n### Warning Generation\n```rust\nfn compute_warnings(issues: &[IssueRow]) -> Vec {\n let now = chrono::Utc::now();\n issues.iter().filter_map(|i| {\n let updated = parse_timestamp(i.updated_at)?;\n let days_stale = (now - updated).num_days();\n if days_stale > 30 {\n Some(format!(\"Issue #{} has no activity for {} days\", i.iid, days_stale))\n } else { None }\n }).chain(\n issues.iter().filter(|i| i.assignees.is_empty())\n .map(|i| format!(\"Issue #{} is unassigned\", i.iid))\n ).collect()\n}\n```\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"mode\": \"topic\",\n \"query\": \"authentication\",\n \"summary\": \"3 open issues, 2 active MRs, top expert: teernisse\",\n \"open_issues\": [{ \"iid\": 123, \"title\": \"...\", \"state\": \"opened\", \"assignees\": [...], \"updated_at\": \"...\", \"labels\": [...] }],\n \"active_mrs\": [{ \"iid\": 456, \"title\": \"...\", \"state\": \"opened\", \"author\": \"...\", \"draft\": false, \"updated_at\": \"...\" }],\n \"experts\": [{ \"username\": \"teernisse\", \"score\": 42, \"last_activity\": \"...\" }],\n \"recent_activity\": [{ \"timestamp\": \"...\", \"event_type\": \"state_change\", \"entity_ref\": \"issues#123\", \"summary\": \"...\", \"actor\": \"...\" }],\n \"unresolved_threads\": [{ \"discussion_id\": \"abc\", \"entity_ref\": \"issues#123\", \"started_by\": \"...\", \"note_count\": 5, \"last_note_at\": \"...\" }],\n \"related\": [{ \"iid\": 789, \"title\": \"...\", \"similarity_score\": 0.85 }],\n \"warnings\": [\"Issue #3800 has no activity for 45 days\"]\n },\n \"meta\": { \"elapsed_ms\": 1200, \"sections_computed\": [\"open_issues\", \"active_mrs\", \"experts\", \"recent_activity\"] }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nBrief {\n /// Free-text topic, entity type, or omit for project-wide brief\n query: Option,\n /// Focus on a file path (who expert mode)\n #[arg(long)]\n path: Option,\n /// Focus on a person (who workload mode)\n #[arg(long)]\n person: Option,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n /// Maximum items per section\n #[arg(long, default_value = \"5\")]\n section_limit: usize,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/brief.rs:\n- test_brief_topic_returns_all_sections: insert test data, search 'auth', assert all section keys present in response\n- test_brief_path_uses_who_expert: brief --path src/auth/, assert experts section populated\n- test_brief_person_uses_who_workload: brief --person user, assert open_issues filtered to user's assignments\n- test_brief_warnings_stale_issue: insert issue with updated_at > 30 days ago, assert warning generated\n- test_brief_token_budget: robot mode output for topic query is under 12000 bytes (~3000 tokens)\n- test_brief_no_embeddings_graceful: related section omitted (not errored) when no embeddings exist\n- test_brief_empty_topic: zero matches returns valid JSON with empty arrays + \"No data found\" summary\n\nGREEN: Implement brief with section assembly, calling existing run_* functions\n\nVERIFY:\n```bash\ncargo test brief:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J brief 'throw time' | jq '.data | keys'\ncargo run --release -- -J brief 'throw time' | wc -c # target <12000\n```\n\n## Acceptance Criteria\n- [ ] lore brief TOPIC returns all sections for free-text topic\n- [ ] lore brief --path PATH returns path-focused briefing with experts\n- [ ] lore brief --person USERNAME returns person-focused briefing\n- [ ] lore brief issues N returns entity-focused briefing\n- [ ] Robot mode output under 12000 bytes (~3000 tokens)\n- [ ] Each section degrades gracefully if its data source is unavailable\n- [ ] summary field is auto-generated one-liner from section counts\n- [ ] warnings detect: stale issues (>30d), unassigned, no due date\n- [ ] Performance: <2s total (acceptable since composing multiple queries)\n- [ ] Command registered in main.rs and robot-docs\n\n## Edge Cases\n- Topic with zero matches: return empty sections + \"No data found for this topic\" summary\n- Path that nobody has touched: experts empty, related may still have results\n- Person not found in DB: exit code 17 with suggestion\n- All sections empty: still return valid JSON with empty arrays\n- Very broad topic (\"the\"): may return too many results — each section respects its limit cap\n- ListFilters/MrListFilters may not derive Default — construct all fields explicitly if needed\n\n## Dependencies\n- Hybrid search (bd-1ksf) for topic relevance ranking\n- lore who (already shipped) for expertise\n- lore related (bd-8con) for semantic connections (BLOCKER — related section is core to the feature)\n- Timeline pipeline (already shipped) for recent activity\n\n## Dependency Context\n- **bd-1ksf (hybrid search)**: Provides `search_hybrid()` which brief uses for topic mode to find relevant entities. Without it, topic mode falls back to FTS-only via `search_fts()`.\n- **bd-8con (related)**: Provides `run_related()` which brief calls to populate the `related` section with semantically similar entities. This is a blocking dependency — the related section is a core differentiator.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/brief.rs\n- src/cli/commands/mod.rs (add pub mod brief; re-export)\n- src/main.rs (register Brief subcommand in Commands enum, add handle_brief fn)\n- Reuse: list.rs, who.rs, timeline.rs, search.rs, show.rs internals","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:47:22.893231Z","created_by":"tayloreernisse","updated_at":"2026-02-19T14:39:48.071273Z","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-1n5q","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1n5q","depends_on_id":"bd-1ksf","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1n5q","depends_on_id":"bd-8con","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} +{"id":"bd-1n5q","title":"lore brief: situational awareness for topic/module/person","description":"## Background\nComposable capstone command. An agent says \"I am about to work on auth\" and gets everything in one call: open issues, active MRs, experts, recent activity, unresolved threads, related context. Replaces 5 separate lore calls with 1.\n\n## Input Modes\n1. Topic: `lore brief 'authentication'` — FTS search to find relevant entities, aggregate\n2. Path: `lore brief --path src/auth/` — who expert internals for path expertise\n3. Person: `lore brief --person teernisse` — who workload internals\n4. Entity: `lore brief issues 3864` — single entity focus with cross-references\n\n## Section Assembly Architecture\n\n### Reuse existing run_* functions (ship faster, recommended for v1)\nEach section calls existing CLI command functions and converts their output.\n\nIMPORTANT: All existing run_* functions take `&Config`, NOT `&Connection`. The Config contains the db_path and each function opens its own connection internally.\n\n```rust\n// In src/cli/commands/brief.rs\n\nuse crate::cli::commands::list::{run_list_issues, run_list_mrs, ListFilters, MrListFilters};\nuse crate::cli::commands::who::{run_who, WhoArgs, WhoMode};\nuse crate::core::config::Config;\n\npub async fn run_brief(config: &Config, args: BriefArgs) -> Result {\n let mut sections_computed = Vec::new();\n\n // 1. open_issues: reuse list.rs\n // Signature: pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result\n // Located at src/cli/commands/list.rs:268\n let open_issues = run_list_issues(config, ListFilters {\n state: Some(\"opened\".into()),\n limit: Some(5),\n project: args.project.clone(),\n // ... scope by topic/path/person based on mode\n ..Default::default()\n })?;\n sections_computed.push(\"open_issues\");\n\n // 2. active_mrs: reuse list.rs\n // Signature: pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result\n // Located at src/cli/commands/list.rs:476\n let active_mrs = run_list_mrs(config, MrListFilters {\n state: Some(\"opened\".into()),\n limit: Some(5),\n project: args.project.clone(),\n ..Default::default()\n })?;\n sections_computed.push(\"active_mrs\");\n\n // 3. experts: reuse who.rs\n // Signature: pub fn run_who(config: &Config, args: &WhoArgs) -> Result\n // Located at src/cli/commands/who.rs:276\n let experts = run_who(config, &WhoArgs {\n mode: WhoMode::Expert,\n path: args.path.clone(),\n limit: Some(3),\n ..Default::default()\n })?;\n sections_computed.push(\"experts\");\n\n // 4. recent_activity: reuse timeline internals\n // The timeline pipeline is 5-stage (SEED->HYDRATE->EXPAND->COLLECT->RENDER)\n // Types in src/core/timeline.rs, seed in src/core/timeline_seed.rs\n // ...etc\n}\n```\n\nNOTE: ListFilters and MrListFilters may not implement Default. Check before using `..Default::default()`. If they don't, derive it or construct all fields explicitly.\n\n### Concrete Function References (src/cli/commands/)\n| Module | Function | Signature | Line |\n|--------|----------|-----------|------|\n| list.rs | run_list_issues | `(config: &Config, filters: ListFilters) -> Result` | 268 |\n| list.rs | run_list_mrs | `(config: &Config, filters: MrListFilters) -> Result` | 476 |\n| who.rs | run_who | `(config: &Config, args: &WhoArgs) -> Result` | 276 |\n| search.rs | run_search | `(config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool) -> Result` | 61 |\n\nNOTE: run_search is currently synchronous (pub fn, not pub async fn). If bd-1ksf ships first, it becomes async. Brief should handle both cases — call `.await` if async, direct call if sync.\n\n### Section Details\n| Section | Source | Limit | Fallback |\n|---------|--------|-------|----------|\n| open_issues | list.rs with state=opened | 5 | empty array |\n| active_mrs | list.rs with state=opened | 5 | empty array |\n| experts | who.rs Expert mode | 3 | empty array (no path data) |\n| recent_activity | timeline pipeline | 10 events | empty array |\n| unresolved_threads | SQL: discussions WHERE resolved=false | 5 | empty array |\n| related | search_vector() via bd-8con | 5 | omit section (no embeddings) |\n| warnings | computed from dates/state | all | empty array |\n\n### Warning Generation\n```rust\nfn compute_warnings(issues: &[IssueRow]) -> Vec {\n let now = chrono::Utc::now();\n issues.iter().filter_map(|i| {\n let updated = parse_timestamp(i.updated_at)?;\n let days_stale = (now - updated).num_days();\n if days_stale > 30 {\n Some(format!(\"Issue #{} has no activity for {} days\", i.iid, days_stale))\n } else { None }\n }).chain(\n issues.iter().filter(|i| i.assignees.is_empty())\n .map(|i| format!(\"Issue #{} is unassigned\", i.iid))\n ).collect()\n}\n```\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"mode\": \"topic\",\n \"query\": \"authentication\",\n \"summary\": \"3 open issues, 2 active MRs, top expert: teernisse\",\n \"open_issues\": [{ \"iid\": 123, \"title\": \"...\", \"state\": \"opened\", \"assignees\": [...], \"updated_at\": \"...\", \"labels\": [...] }],\n \"active_mrs\": [{ \"iid\": 456, \"title\": \"...\", \"state\": \"opened\", \"author\": \"...\", \"draft\": false, \"updated_at\": \"...\" }],\n \"experts\": [{ \"username\": \"teernisse\", \"score\": 42, \"last_activity\": \"...\" }],\n \"recent_activity\": [{ \"timestamp\": \"...\", \"event_type\": \"state_change\", \"entity_ref\": \"issues#123\", \"summary\": \"...\", \"actor\": \"...\" }],\n \"unresolved_threads\": [{ \"discussion_id\": \"abc\", \"entity_ref\": \"issues#123\", \"started_by\": \"...\", \"note_count\": 5, \"last_note_at\": \"...\" }],\n \"related\": [{ \"iid\": 789, \"title\": \"...\", \"similarity_score\": 0.85 }],\n \"warnings\": [\"Issue #3800 has no activity for 45 days\"]\n },\n \"meta\": { \"elapsed_ms\": 1200, \"sections_computed\": [\"open_issues\", \"active_mrs\", \"experts\", \"recent_activity\"] }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nBrief {\n /// Free-text topic, entity type, or omit for project-wide brief\n query: Option,\n /// Focus on a file path (who expert mode)\n #[arg(long)]\n path: Option,\n /// Focus on a person (who workload mode)\n #[arg(long)]\n person: Option,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n /// Maximum items per section\n #[arg(long, default_value = \"5\")]\n section_limit: usize,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/brief.rs:\n- test_brief_topic_returns_all_sections: insert test data, search 'auth', assert all section keys present in response\n- test_brief_path_uses_who_expert: brief --path src/auth/, assert experts section populated\n- test_brief_person_uses_who_workload: brief --person user, assert open_issues filtered to user's assignments\n- test_brief_warnings_stale_issue: insert issue with updated_at > 30 days ago, assert warning generated\n- test_brief_token_budget: robot mode output for topic query is under 12000 bytes (~3000 tokens)\n- test_brief_no_embeddings_graceful: related section omitted (not errored) when no embeddings exist\n- test_brief_empty_topic: zero matches returns valid JSON with empty arrays + \"No data found\" summary\n\nGREEN: Implement brief with section assembly, calling existing run_* functions\n\nVERIFY:\n```bash\ncargo test brief:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J brief 'throw time' | jq '.data | keys'\ncargo run --release -- -J brief 'throw time' | wc -c # target <12000\n```\n\n## Acceptance Criteria\n- [ ] lore brief TOPIC returns all sections for free-text topic\n- [ ] lore brief --path PATH returns path-focused briefing with experts\n- [ ] lore brief --person USERNAME returns person-focused briefing\n- [ ] lore brief issues N returns entity-focused briefing\n- [ ] Robot mode output under 12000 bytes (~3000 tokens)\n- [ ] Each section degrades gracefully if its data source is unavailable\n- [ ] summary field is auto-generated one-liner from section counts\n- [ ] warnings detect: stale issues (>30d), unassigned, no due date\n- [ ] Performance: <2s total (acceptable since composing multiple queries)\n- [ ] Command registered in main.rs and robot-docs\n\n## Edge Cases\n- Topic with zero matches: return empty sections + \"No data found for this topic\" summary\n- Path that nobody has touched: experts empty, related may still have results\n- Person not found in DB: exit code 17 with suggestion\n- All sections empty: still return valid JSON with empty arrays\n- Very broad topic (\"the\"): may return too many results — each section respects its limit cap\n- ListFilters/MrListFilters may not derive Default — construct all fields explicitly if needed\n\n## Dependencies\n- Hybrid search (bd-1ksf) for topic relevance ranking\n- lore who (already shipped) for expertise\n- lore related (bd-8con) for semantic connections (BLOCKER — related section is core to the feature)\n- Timeline pipeline (already shipped) for recent activity\n\n## Dependency Context\n- **bd-1ksf (hybrid search)**: Provides `search_hybrid()` which brief uses for topic mode to find relevant entities. Without it, topic mode falls back to FTS-only via `search_fts()`.\n- **bd-8con (related)**: Provides `run_related()` which brief calls to populate the `related` section with semantically similar entities. This is a blocking dependency — the related section is a core differentiator.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/brief.rs\n- src/cli/commands/mod.rs (add pub mod brief; re-export)\n- src/main.rs (register Brief subcommand in Commands enum, add handle_brief fn)\n- Reuse: list.rs, who.rs, timeline.rs, search.rs, show.rs internals","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:47:22.893231Z","created_by":"tayloreernisse","updated_at":"2026-02-19T14:51:30.969098Z","closed_at":"2026-02-19T14:51:30.968887Z","close_reason":"Implemented lore brief: composable situational awareness command. 3 modes (topic/path/person), 7 sections (open_issues, active_mrs, experts, recent_activity, unresolved_threads, related, warnings). 7 unit tests, robot-docs, autocorrect, graceful degradation.","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-1n5q","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1n5q","depends_on_id":"bd-1ksf","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1n5q","depends_on_id":"bd-8con","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-1nf","title":"Register 'lore timeline' command with all flags","description":"## Background\n\nThis bead wires the `lore timeline` command into the CLI — adding the subcommand to the Commands enum, defining all flags, registering in VALID_COMMANDS, and dispatching to the timeline handler. The actual query logic and rendering are in separate beads.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.1 (Command Design).\n\n## Codebase Context\n\n- Commands enum in `src/cli/mod.rs` (line ~86): uses #[derive(Subcommand)] with nested Args structs\n- VALID_COMMANDS in `src/main.rs` (line ~448): &[&str] array for fuzzy command matching\n- Handler dispatch in `src/main.rs` match on Commands:: variants\n- robot-docs manifest in `src/main.rs`: registers commands for `lore robot-docs` output\n- Existing pattern: `Sync(SyncArgs)`, `Search(SearchArgs)`, etc.\n- No timeline module exists yet — this bead creates the CLI entry point only\n\n## Approach\n\n### 1. TimelineArgs struct (`src/cli/mod.rs`):\n\n```rust\n/// Show a chronological timeline of events matching a query\n#[derive(Parser, Debug)]\npub struct TimelineArgs {\n /// Search query (keywords to find in issues, MRs, and discussions)\n pub query: String,\n\n /// Scope to a specific project (fuzzy match)\n #[arg(short = 'p', long)]\n pub project: Option,\n\n /// Only show events after this date (e.g. \"6m\", \"2w\", \"2024-01-01\")\n #[arg(long)]\n pub since: Option,\n\n /// Cross-reference expansion depth (0 = no expansion)\n #[arg(long, default_value = \"1\")]\n pub depth: usize,\n\n /// Also follow 'mentioned' edges during expansion (high fan-out)\n #[arg(long = \"expand-mentions\")]\n pub expand_mentions: bool,\n\n /// Maximum number of events to display\n #[arg(short = 'n', long = \"limit\", default_value = \"100\")]\n pub limit: usize,\n}\n```\n\n### 2. Commands enum variant:\n\n```rust\n/// Show a chronological timeline of events matching a query\n#[command(name = \"timeline\")]\nTimeline(TimelineArgs),\n```\n\n### 3. Handler in `src/main.rs`:\n\n```rust\nCommands::Timeline(args) => {\n // Placeholder: will be filled by bd-2f2 (human) and bd-dty (robot)\n // For now: resolve project, call timeline query, dispatch to renderer\n}\n```\n\n### 4. VALID_COMMANDS: add `\"timeline\"` to the array\n\n### 5. robot-docs: add timeline command description to manifest\n\n## Acceptance Criteria\n\n- [ ] `TimelineArgs` struct with all 6 flags: query, project, since, depth, expand-mentions, limit\n- [ ] Commands::Timeline variant registered in Commands enum\n- [ ] Handler stub in src/main.rs dispatches to timeline logic\n- [ ] `\"timeline\"` added to VALID_COMMANDS array\n- [ ] robot-docs manifest includes timeline command description\n- [ ] `lore timeline --help` shows correct help text\n- [ ] `lore timeline` without query shows error (query is required positional)\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/mod.rs` (TimelineArgs struct + Commands::Timeline variant)\n- `src/main.rs` (handler dispatch + VALID_COMMANDS + robot-docs entry)\n\n## TDD Loop\n\nNo unit tests for CLI wiring. Verify with:\n\n```bash\ncargo check --all-targets\ncargo run -- timeline --help\n```\n\n## Edge Cases\n\n- --since parsing: reuse existing date parsing from ListFilters (src/cli/mod.rs handles \"7d\", \"2w\", \"YYYY-MM-DD\")\n- --depth 0: valid, means no cross-reference expansion\n- --expand-mentions: off by default because mentioned edges have high fan-out\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.422082Z","created_by":"tayloreernisse","updated_at":"2026-02-06T13:49:15.313047Z","closed_at":"2026-02-06T13:49:15.312993Z","close_reason":"Wired lore timeline command: TimelineArgs with 9 flags, Commands::Timeline variant, handle_timeline handler, VALID_COMMANDS entry, robot-docs manifest with temporal_intelligence workflow","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-1nf","depends_on_id":"bd-2f2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1nf","depends_on_id":"bd-dty","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1nf","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-1np","title":"[CP1] GitLab types for issues, discussions, notes","description":"## Background\n\nGitLab types define the Rust structs for deserializing GitLab API responses. These types are the foundation for all ingestion work - issues, discussions, and notes must be correctly typed for serde to parse them.\n\n## Approach\n\nAdd types to `src/gitlab/types.rs` with serde derives:\n\n### GitLabIssue\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabIssue {\n pub id: i64, // GitLab global ID\n pub iid: i64, // Project-scoped issue number\n pub project_id: i64,\n pub title: String,\n pub description: Option,\n pub state: String, // \"opened\" | \"closed\"\n pub created_at: String, // ISO 8601\n pub updated_at: String, // ISO 8601\n pub closed_at: Option,\n pub author: GitLabAuthor,\n pub labels: Vec, // Array of label names (CP1 canonical)\n pub web_url: String,\n}\n```\n\nNOTE: `labels_details` intentionally NOT modeled - varies across GitLab versions.\n\n### GitLabAuthor\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabAuthor {\n pub id: i64,\n pub username: String,\n pub name: String,\n}\n```\n\n### GitLabDiscussion\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabDiscussion {\n pub id: String, // String ID like \"6a9c1750b37d...\"\n pub individual_note: bool, // true = standalone comment\n pub notes: Vec,\n}\n```\n\n### GitLabNote\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabNote {\n pub id: i64,\n #[serde(rename = \"type\")]\n pub note_type: Option, // \"DiscussionNote\" | \"DiffNote\" | null\n pub body: String,\n pub author: GitLabAuthor,\n pub created_at: String, // ISO 8601\n pub updated_at: String, // ISO 8601\n pub system: bool, // true for system-generated notes\n #[serde(default)]\n pub resolvable: bool,\n #[serde(default)]\n pub resolved: bool,\n pub resolved_by: Option,\n pub resolved_at: Option,\n pub position: Option,\n}\n```\n\n### GitLabNotePosition\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabNotePosition {\n pub old_path: Option,\n pub new_path: Option,\n pub old_line: Option,\n pub new_line: Option,\n}\n```\n\n## Acceptance Criteria\n\n- [ ] GitLabIssue deserializes from API response JSON\n- [ ] GitLabAuthor embedded correctly in issue and note\n- [ ] GitLabDiscussion with notes array deserializes\n- [ ] GitLabNote handles null note_type (use Option)\n- [ ] GitLabNote uses #[serde(rename = \"type\")] for reserved keyword\n- [ ] resolvable/resolved default to false via #[serde(default)]\n- [ ] All timestamp fields are String (ISO 8601 parsed elsewhere)\n\n## Files\n\n- src/gitlab/types.rs (edit - add types)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/gitlab_types_tests.rs\n#[test] fn deserializes_gitlab_issue_from_json()\n#[test] fn deserializes_gitlab_discussion_from_json()\n#[test] fn handles_null_note_type()\n#[test] fn handles_missing_resolvable_field()\n#[test] fn deserializes_labels_as_string_array()\n```\n\nGREEN: Add type definitions with serde attributes\n\nVERIFY: `cargo test gitlab_types`\n\n## Edge Cases\n\n- note_type can be null, \"DiscussionNote\", or \"DiffNote\"\n- labels array can be empty\n- description can be null\n- resolved_by/resolved_at can be null\n- position is only present for DiffNotes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.150472Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:17:08.842965Z","closed_at":"2026-01-25T22:17:08.842895Z","close_reason":"Implemented GitLabAuthor, GitLabIssue, GitLabDiscussion, GitLabNote, GitLabNotePosition types with 10 passing tests","compaction_level":0,"original_size":0} {"id":"bd-1nsl","title":"Epic: Surgical Per-IID Sync","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-17T19:11:34.020453Z","created_by":"tayloreernisse","updated_at":"2026-02-19T13:47:02.942871Z","closed_at":"2026-02-19T13:47:02.942827Z","close_reason":"All child beads completed: bd-wcja (SyncResult fields), bd-1elx (SurgicalTarget types), bd-hs6j (TOCTOU guard), bd-3sez (preflight_fetch), bd-arka (ingest_issue_by_iid), bd-tiux (ingest_mr_by_iid), bd-1lja (SyncOptions extensions), bd-159p (dirty_tracker), bd-1sc6 (SyncRunRecorder), bd-kanh (enrichment helpers), bd-1i4i (orchestrator), bd-3bec (dispatch wiring). 886 tests pass.","compaction_level":0,"original_size":0,"labels":["surgical-sync"]} diff --git a/.beads/last-touched b/.beads/last-touched index 0c1bd4d..168b9ce 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -bd-2cbw +bd-1n5q diff --git a/src/cli/autocorrect.rs b/src/cli/autocorrect.rs index 27af1d0..29f58c7 100644 --- a/src/cli/autocorrect.rs +++ b/src/cli/autocorrect.rs @@ -290,6 +290,10 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[ ("reset", &["--yes"]), ("related", &["--limit", "--project"]), ("explain", &["--project"]), + ( + "brief", + &["--path", "--person", "--project", "--section-limit"], + ), ]; /// Valid values for enum-like flags, used for post-clap error enhancement. diff --git a/src/cli/commands/brief.rs b/src/cli/commands/brief.rs new file mode 100644 index 0000000..483918a --- /dev/null +++ b/src/cli/commands/brief.rs @@ -0,0 +1,838 @@ +use serde::Serialize; + +use crate::cli::WhoArgs; +use crate::cli::commands::list::{IssueListRow, ListFilters, MrListFilters, MrListRow}; +use crate::cli::commands::related::RelatedResult; +use crate::cli::commands::who::WhoRun; +use crate::core::config::Config; +use crate::core::db::create_connection; +use crate::core::error::Result; +use crate::core::paths::get_db_path; +use crate::core::time::ms_to_iso; + +// ─── Public Types ────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct BriefResponse { + pub mode: String, + pub query: Option, + pub summary: String, + pub open_issues: Vec, + pub active_mrs: Vec, + pub experts: Vec, + pub recent_activity: Vec, + pub unresolved_threads: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub related: Vec, + pub warnings: Vec, + pub sections_computed: Vec, +} + +#[derive(Debug, Serialize)] +pub struct BriefIssue { + pub iid: i64, + pub title: String, + pub state: String, + pub assignees: Vec, + pub labels: Vec, + pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_name: Option, + pub unresolved_count: i64, +} + +#[derive(Debug, Serialize)] +pub struct BriefMr { + pub iid: i64, + pub title: String, + pub state: String, + pub author: String, + pub draft: bool, + pub labels: Vec, + pub updated_at: String, + pub unresolved_count: i64, +} + +#[derive(Debug, Serialize)] +pub struct BriefExpert { + pub username: String, + pub score: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_activity: Option, +} + +#[derive(Debug, Serialize)] +pub struct BriefActivity { + pub timestamp: String, + pub event_type: String, + pub entity_ref: String, + pub summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub actor: Option, +} + +#[derive(Debug, Serialize)] +pub struct BriefThread { + pub discussion_id: String, + pub entity_type: String, + pub entity_iid: i64, + pub started_by: String, + pub note_count: i64, + pub last_note_at: String, + pub first_note_body: String, +} + +#[derive(Debug, Serialize)] +pub struct BriefRelated { + pub source_type: String, + pub iid: i64, + pub title: String, + pub similarity_score: f64, +} + +// ─── Input ───────────────────────────────────────────────────────────────── + +pub struct BriefArgs { + pub query: Option, + pub path: Option, + pub person: Option, + pub project: Option, + pub section_limit: usize, +} + +// ─── Conversion helpers ──────────────────────────────────────────────────── + +fn issue_to_brief(row: &IssueListRow) -> BriefIssue { + BriefIssue { + iid: row.iid, + title: row.title.clone(), + state: row.state.clone(), + assignees: row.assignees.clone(), + labels: row.labels.clone(), + updated_at: ms_to_iso(row.updated_at), + status_name: row.status_name.clone(), + unresolved_count: row.unresolved_count, + } +} + +fn mr_to_brief(row: &MrListRow) -> BriefMr { + BriefMr { + iid: row.iid, + title: row.title.clone(), + state: row.state.clone(), + author: row.author_username.clone(), + draft: row.draft, + labels: row.labels.clone(), + updated_at: ms_to_iso(row.updated_at), + unresolved_count: row.unresolved_count, + } +} + +fn related_to_brief(r: &RelatedResult) -> BriefRelated { + BriefRelated { + source_type: r.source_type.clone(), + iid: r.iid, + title: r.title.clone(), + similarity_score: r.similarity_score, + } +} + +fn experts_from_who_run(run: &WhoRun) -> Vec { + use crate::core::who_types::WhoResult; + match &run.result { + WhoResult::Expert(er) => er + .experts + .iter() + .map(|e| BriefExpert { + username: e.username.clone(), + score: e.score as f64, + last_activity: Some(ms_to_iso(e.last_seen_ms)), + }) + .collect(), + WhoResult::Workload(wr) => { + vec![BriefExpert { + username: wr.username.clone(), + score: 0.0, + last_activity: None, + }] + } + _ => vec![], + } +} + +// ─── Warning heuristics ──────────────────────────────────────────────────── + +const STALE_THRESHOLD_MS: i64 = 30 * 24 * 60 * 60 * 1000; // 30 days + +fn compute_warnings(issues: &[IssueListRow], mrs: &[MrListRow]) -> Vec { + let now = chrono::Utc::now().timestamp_millis(); + let mut warnings = Vec::new(); + + for i in issues { + let age_ms = now - i.updated_at; + if age_ms > STALE_THRESHOLD_MS { + let days = age_ms / (24 * 60 * 60 * 1000); + warnings.push(format!( + "Issue #{} has no activity for {} days", + i.iid, days + )); + } + if i.assignees.is_empty() && i.state == "opened" { + warnings.push(format!("Issue #{} is unassigned", i.iid)); + } + } + for m in mrs { + let age_ms = now - m.updated_at; + if age_ms > STALE_THRESHOLD_MS { + let days = age_ms / (24 * 60 * 60 * 1000); + warnings.push(format!("MR !{} has no activity for {} days", m.iid, days)); + } + if m.unresolved_count > 0 && m.state == "opened" { + warnings.push(format!( + "MR !{} has {} unresolved threads", + m.iid, m.unresolved_count + )); + } + } + + warnings +} + +fn build_summary(response: &BriefResponse) -> String { + let parts: Vec = [ + (!response.open_issues.is_empty()) + .then(|| format!("{} open issues", response.open_issues.len())), + (!response.active_mrs.is_empty()) + .then(|| format!("{} active MRs", response.active_mrs.len())), + (!response.experts.is_empty()).then(|| { + format!( + "top expert: {}", + response.experts.first().map_or("none", |e| &e.username) + ) + }), + (!response.warnings.is_empty()).then(|| format!("{} warnings", response.warnings.len())), + ] + .into_iter() + .flatten() + .collect(); + + if parts.is_empty() { + "No data found".to_string() + } else { + parts.join(", ") + } +} + +// ─── Unresolved threads (direct SQL) ─────────────────────────────────────── + +fn query_unresolved_threads( + config: &Config, + project: Option<&str>, + limit: usize, +) -> Result> { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let project_id: Option = project + .map(|p| crate::core::project::resolve_project(&conn, p)) + .transpose()?; + + let (sql, params): (String, Vec>) = if let Some(pid) = project_id { + ( + format!( + "SELECT d.gitlab_discussion_id, d.noteable_type, d.noteable_id, + n.author_username, COUNT(n.id) as note_count, + MAX(n.created_at_ms) as last_note_at, + MIN(CASE WHEN n.system = 0 THEN n.body END) as first_body + FROM discussions d + JOIN notes n ON n.discussion_id = d.id + WHERE d.resolved = 0 + AND d.project_id = ? + GROUP BY d.id + ORDER BY last_note_at DESC + LIMIT {limit}" + ), + vec![Box::new(pid)], + ) + } else { + ( + format!( + "SELECT d.gitlab_discussion_id, d.noteable_type, d.noteable_id, + n.author_username, COUNT(n.id) as note_count, + MAX(n.created_at_ms) as last_note_at, + MIN(CASE WHEN n.system = 0 THEN n.body END) as first_body + FROM discussions d + JOIN notes n ON n.discussion_id = d.id + WHERE d.resolved = 0 + GROUP BY d.id + ORDER BY last_note_at DESC + LIMIT {limit}" + ), + vec![], + ) + }; + + let mut stmt = conn.prepare(&sql)?; + let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_refs.as_slice(), |row| { + let noteable_id: i64 = row.get(2)?; + let noteable_type: String = row.get(1)?; + let last_note_ms: i64 = row.get(5)?; + let body: Option = row.get(6)?; + + // Look up the IID from the entity table + Ok(BriefThread { + discussion_id: row.get(0)?, + entity_type: noteable_type, + entity_iid: noteable_id, // We'll resolve IID below + started_by: row.get(3)?, + note_count: row.get(4)?, + last_note_at: ms_to_iso(last_note_ms), + first_note_body: truncate_body(&body.unwrap_or_default(), 120), + }) + })? + .filter_map(|r| r.ok()) + .collect::>(); + + // Resolve noteable_id -> IID. noteable_id is the internal DB id, not the IID. + // For now, we use noteable_id as a best-effort proxy since the discussions table + // stores noteable_id which is the row PK in issues/merge_requests table. + let mut resolved = Vec::with_capacity(rows.len()); + for mut t in rows { + let iid_result: rusqlite::Result = if t.entity_type == "Issue" { + conn.query_row( + "SELECT iid FROM issues WHERE id = ?", + [t.entity_iid], + |row| row.get(0), + ) + } else { + conn.query_row( + "SELECT iid FROM merge_requests WHERE id = ?", + [t.entity_iid], + |row| row.get(0), + ) + }; + if let Ok(iid) = iid_result { + t.entity_iid = iid; + } + resolved.push(t); + } + + Ok(resolved) +} + +fn truncate_body(s: &str, max_len: usize) -> String { + let first_line = s.lines().next().unwrap_or(""); + if first_line.len() <= max_len { + first_line.to_string() + } else { + let mut end = max_len; + while !first_line.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &first_line[..end]) + } +} + +// ─── Recent activity (direct SQL, lightweight) ───────────────────────────── + +fn query_recent_activity( + config: &Config, + project: Option<&str>, + limit: usize, +) -> Result> { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let project_id: Option = project + .map(|p| crate::core::project::resolve_project(&conn, p)) + .transpose()?; + + // Combine state events and non-system notes into a timeline + let mut events: Vec = Vec::new(); + + // State events + { + let (sql, params): (String, Vec>) = if let Some(pid) = project_id { + ( + format!( + "SELECT rse.created_at, rse.state, rse.actor_username, + COALESCE(i.iid, mr.iid) as entity_iid, + CASE WHEN rse.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END as etype + FROM resource_state_events rse + LEFT JOIN issues i ON i.id = rse.issue_id + LEFT JOIN merge_requests mr ON mr.id = rse.merge_request_id + WHERE (i.project_id = ? OR mr.project_id = ?) + ORDER BY rse.created_at DESC + LIMIT {limit}" + ), + vec![Box::new(pid) as Box, Box::new(pid)], + ) + } else { + ( + format!( + "SELECT rse.created_at, rse.state, rse.actor_username, + COALESCE(i.iid, mr.iid) as entity_iid, + CASE WHEN rse.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END as etype + FROM resource_state_events rse + LEFT JOIN issues i ON i.id = rse.issue_id + LEFT JOIN merge_requests mr ON mr.id = rse.merge_request_id + ORDER BY rse.created_at DESC + LIMIT {limit}" + ), + vec![], + ) + }; + + let mut stmt = conn.prepare(&sql)?; + let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt.query_map(params_refs.as_slice(), |row| { + let ts: i64 = row.get(0)?; + let state: String = row.get(1)?; + let actor: Option = row.get(2)?; + let iid: Option = row.get(3)?; + let etype: String = row.get(4)?; + Ok(BriefActivity { + timestamp: ms_to_iso(ts), + event_type: "state_change".to_string(), + entity_ref: format!( + "{}#{}", + if etype == "issue" { "issues" } else { "mrs" }, + iid.unwrap_or(0) + ), + summary: format!("State changed to {state}"), + actor, + }) + })?; + for row in rows.flatten() { + events.push(row); + } + } + + // Sort by timestamp descending and truncate + events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + events.truncate(limit); + + Ok(events) +} + +// ─── Main entry point ────────────────────────────────────────────────────── + +pub async fn run_brief(config: &Config, args: &BriefArgs) -> Result { + use crate::cli::commands::list::{run_list_issues, run_list_mrs}; + use crate::cli::commands::related::run_related; + use crate::cli::commands::who::run_who; + + let limit = args.section_limit; + let mut sections = Vec::new(); + + let mode = if args.path.is_some() { + "path" + } else if args.person.is_some() { + "person" + } else { + "topic" + }; + + // ── 1. Open issues ───────────────────────────────────────────────────── + let empty_statuses: Vec = vec![]; + let assignee_filter = args.person.as_deref(); + + let issue_result = run_list_issues( + config, + ListFilters { + limit, + project: args.project.as_deref(), + state: Some("opened"), + author: None, + assignee: assignee_filter, + labels: None, + milestone: None, + since: None, + due_before: None, + has_due_date: false, + statuses: &empty_statuses, + sort: "updated_at", + order: "desc", + }, + ); + + let (open_issues, raw_issue_list): (Vec, Vec) = match issue_result { + Ok(r) => { + sections.push("open_issues".to_string()); + let brief: Vec = r.issues.iter().map(issue_to_brief).collect(); + (brief, r.issues) + } + Err(_) => (vec![], vec![]), + }; + + // ── 2. Active MRs ────────────────────────────────────────────────────── + let mr_result = run_list_mrs( + config, + MrListFilters { + limit, + project: args.project.as_deref(), + state: Some("opened"), + author: args.person.as_deref(), + assignee: None, + reviewer: None, + labels: None, + since: None, + draft: false, + no_draft: false, + target_branch: None, + source_branch: None, + sort: "updated_at", + order: "desc", + }, + ); + + let (active_mrs, raw_mr_list): (Vec, Vec) = match mr_result { + Ok(r) => { + sections.push("active_mrs".to_string()); + let brief: Vec = r.mrs.iter().map(mr_to_brief).collect(); + (brief, r.mrs) + } + Err(_) => (vec![], vec![]), + }; + + // ── 3. Experts (only for path mode or if query looks like a path) ────── + let experts: Vec = if args.path.is_some() { + let who_args = WhoArgs { + target: None, + path: args.path.clone(), + active: false, + overlap: None, + reviews: false, + since: None, + project: args.project.clone(), + limit: 3, + fields: None, + detail: false, + no_detail: false, + as_of: None, + explain_score: false, + include_bots: false, + include_closed: false, + all_history: false, + }; + match run_who(config, &who_args) { + Ok(run) => { + sections.push("experts".to_string()); + experts_from_who_run(&run) + } + Err(_) => vec![], + } + } else if let Some(person) = &args.person { + let who_args = WhoArgs { + target: Some(person.clone()), + path: None, + active: false, + overlap: None, + reviews: false, + since: None, + project: args.project.clone(), + limit: 3, + fields: None, + detail: false, + no_detail: false, + as_of: None, + explain_score: false, + include_bots: false, + include_closed: false, + all_history: false, + }; + match run_who(config, &who_args) { + Ok(run) => { + sections.push("experts".to_string()); + experts_from_who_run(&run) + } + Err(_) => vec![], + } + } else { + vec![] + }; + + // ── 4. Recent activity ───────────────────────────────────────────────── + let recent_activity = + query_recent_activity(config, args.project.as_deref(), limit).unwrap_or_default(); + if !recent_activity.is_empty() { + sections.push("recent_activity".to_string()); + } + + // ── 5. Unresolved threads ────────────────────────────────────────────── + let unresolved_threads = + query_unresolved_threads(config, args.project.as_deref(), limit).unwrap_or_default(); + if !unresolved_threads.is_empty() { + sections.push("unresolved_threads".to_string()); + } + + // ── 6. Related (only for topic mode with a query) ────────────────────── + let related: Vec = if let Some(q) = &args.query { + match run_related(config, None, None, Some(q), args.project.as_deref(), limit).await { + Ok(resp) => { + if !resp.results.is_empty() { + sections.push("related".to_string()); + } + resp.results.iter().map(related_to_brief).collect() + } + Err(_) => vec![], // Graceful degradation: no embeddings = no related + } + } else { + vec![] + }; + + // ── 7. Warnings ──────────────────────────────────────────────────────── + let warnings = compute_warnings(&raw_issue_list, &raw_mr_list); + + // ── Build response ───────────────────────────────────────────────────── + let mut response = BriefResponse { + mode: mode.to_string(), + query: args.query.clone(), + summary: String::new(), // Computed below + open_issues, + active_mrs, + experts, + recent_activity, + unresolved_threads, + related, + warnings, + sections_computed: sections, + }; + response.summary = build_summary(&response); + + Ok(response) +} + +// ─── Output formatters ───────────────────────────────────────────────────── + +pub fn print_brief_json(response: &BriefResponse, elapsed_ms: u64) { + let output = serde_json::json!({ + "ok": true, + "data": response, + "meta": { + "elapsed_ms": elapsed_ms, + "sections_computed": response.sections_computed, + } + }); + println!("{}", serde_json::to_string(&output).unwrap_or_default()); +} + +pub fn print_brief_human(response: &BriefResponse) { + println!("=== Brief: {} ===", response.summary); + println!(); + + if !response.open_issues.is_empty() { + println!("--- Open Issues ({}) ---", response.open_issues.len()); + for i in &response.open_issues { + let status = i + .status_name + .as_deref() + .map_or(String::new(), |s| format!(" [{s}]")); + println!(" #{} {}{}", i.iid, i.title, status); + if !i.assignees.is_empty() { + println!(" assignees: {}", i.assignees.join(", ")); + } + } + println!(); + } + + if !response.active_mrs.is_empty() { + println!("--- Active MRs ({}) ---", response.active_mrs.len()); + for m in &response.active_mrs { + let draft = if m.draft { " [DRAFT]" } else { "" }; + println!(" !{} {}{} by {}", m.iid, m.title, draft, m.author); + } + println!(); + } + + if !response.experts.is_empty() { + println!("--- Experts ({}) ---", response.experts.len()); + for e in &response.experts { + println!(" {} (score: {:.1})", e.username, e.score); + } + println!(); + } + + if !response.recent_activity.is_empty() { + println!( + "--- Recent Activity ({}) ---", + response.recent_activity.len() + ); + for a in &response.recent_activity { + let actor = a.actor.as_deref().unwrap_or("system"); + println!( + " {} {} | {} | {}", + a.timestamp, actor, a.entity_ref, a.summary + ); + } + println!(); + } + + if !response.unresolved_threads.is_empty() { + println!( + "--- Unresolved Threads ({}) ---", + response.unresolved_threads.len() + ); + for t in &response.unresolved_threads { + println!( + " {}#{} by {} ({} notes): {}", + t.entity_type, t.entity_iid, t.started_by, t.note_count, t.first_note_body + ); + } + println!(); + } + + if !response.related.is_empty() { + println!("--- Related ({}) ---", response.related.len()); + for r in &response.related { + println!( + " {}#{} {} (sim: {:.2})", + r.source_type, r.iid, r.title, r.similarity_score + ); + } + println!(); + } + + if !response.warnings.is_empty() { + println!("--- Warnings ({}) ---", response.warnings.len()); + for w in &response.warnings { + println!(" {w}"); + } + println!(); + } +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_body_short() { + assert_eq!(truncate_body("hello world", 20), "hello world"); + } + + #[test] + fn test_truncate_body_long() { + let long = "a".repeat(200); + let result = truncate_body(&long, 50); + assert!(result.ends_with("...")); + // 50 chars + "..." + assert_eq!(result.len(), 53); + } + + #[test] + fn test_truncate_body_multiline() { + let text = "first line\nsecond line\nthird line"; + assert_eq!(truncate_body(text, 100), "first line"); + } + + #[test] + fn test_build_summary_empty() { + let response = BriefResponse { + mode: "topic".to_string(), + query: Some("auth".to_string()), + summary: String::new(), + open_issues: vec![], + active_mrs: vec![], + experts: vec![], + recent_activity: vec![], + unresolved_threads: vec![], + related: vec![], + warnings: vec![], + sections_computed: vec![], + }; + assert_eq!(build_summary(&response), "No data found"); + } + + #[test] + fn test_build_summary_with_data() { + let response = BriefResponse { + mode: "topic".to_string(), + query: Some("auth".to_string()), + summary: String::new(), + open_issues: vec![BriefIssue { + iid: 1, + title: "test".to_string(), + state: "opened".to_string(), + assignees: vec![], + labels: vec![], + updated_at: "2024-01-01".to_string(), + status_name: None, + unresolved_count: 0, + }], + active_mrs: vec![], + experts: vec![BriefExpert { + username: "alice".to_string(), + score: 42.0, + last_activity: None, + }], + recent_activity: vec![], + unresolved_threads: vec![], + related: vec![], + warnings: vec!["stale".to_string()], + sections_computed: vec![], + }; + let summary = build_summary(&response); + assert!(summary.contains("1 open issues")); + assert!(summary.contains("top expert: alice")); + assert!(summary.contains("1 warnings")); + } + + #[test] + fn test_compute_warnings_stale_issue() { + let now = chrono::Utc::now().timestamp_millis(); + let old = now - (45 * 24 * 60 * 60 * 1000); // 45 days ago + let issues = vec![IssueListRow { + iid: 42, + title: "Old issue".to_string(), + state: "opened".to_string(), + author_username: "alice".to_string(), + created_at: old, + updated_at: old, + web_url: None, + project_path: "group/repo".to_string(), + labels: vec![], + assignees: vec![], + discussion_count: 0, + unresolved_count: 0, + status_name: None, + status_category: None, + status_color: None, + status_icon_name: None, + status_synced_at: None, + }]; + let warnings = compute_warnings(&issues, &[]); + assert!(warnings.iter().any(|w| w.contains("Issue #42"))); + assert!(warnings.iter().any(|w| w.contains("unassigned"))); + } + + #[test] + fn test_compute_warnings_unresolved_mr() { + let now = chrono::Utc::now().timestamp_millis(); + let mrs = vec![MrListRow { + iid: 99, + title: "WIP MR".to_string(), + state: "opened".to_string(), + draft: false, + author_username: "bob".to_string(), + source_branch: "feat".to_string(), + target_branch: "main".to_string(), + created_at: now, + updated_at: now, + web_url: None, + project_path: "group/repo".to_string(), + labels: vec![], + assignees: vec![], + reviewers: vec![], + discussion_count: 3, + unresolved_count: 2, + }]; + let warnings = compute_warnings(&[], &mrs); + assert!(warnings.iter().any(|w| w.contains("MR !99"))); + assert!(warnings.iter().any(|w| w.contains("2 unresolved"))); + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 946c6dd..0950f85 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,4 +1,5 @@ pub mod auth_test; +pub mod brief; pub mod count; pub mod doctor; pub mod drift; @@ -22,6 +23,7 @@ pub mod tui; pub mod who; pub use auth_test::run_auth_test; +pub use brief::{BriefArgs, BriefResponse, print_brief_human, print_brief_json, run_brief}; pub use count::{ print_count, print_count_json, print_event_count, print_event_count_json, print_reference_count, print_reference_count_json, run_count, run_count_events, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c1715ea..8a43dea 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -250,6 +250,28 @@ pub enum Commands { #[command(visible_alias = "similar")] Related(RelatedArgs), + /// Situational awareness: open issues, active MRs, experts, activity, threads + Brief { + /// Free-text topic, entity type, or omit for project-wide brief + query: Option, + + /// Focus on a file path (who expert mode) + #[arg(long)] + path: Option, + + /// Focus on a person (who workload mode) + #[arg(long)] + person: Option, + + /// Scope to project (fuzzy match) + #[arg(short, long)] + project: Option, + + /// Maximum items per section + #[arg(long, default_value = "5")] + section_limit: usize, + }, + /// Auto-generate a structured narrative for an issue or MR Explain { /// Entity type: "issues" or "mrs" diff --git a/src/ingestion/issues_tests.rs b/src/ingestion/issues_tests.rs index 59b6b71..f7af624 100644 --- a/src/ingestion/issues_tests.rs +++ b/src/ingestion/issues_tests.rs @@ -1,5 +1,46 @@ +use std::path::Path; + use super::*; -use crate::gitlab::types::GitLabAuthor; +use crate::core::config::{ + EmbeddingConfig, GitLabConfig, LoggingConfig, ProjectConfig, ScoringConfig, StorageConfig, + SyncConfig, +}; +use crate::core::db::{create_connection, run_migrations}; +use crate::gitlab::types::{GitLabAuthor, GitLabMilestone}; + +// ─── Test Helpers ─────────────────────────────────────────────────────────── + +fn setup_test_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) + VALUES (1, 100, 'group/project', 'https://gitlab.example.com/group/project')", + [], + ) + .unwrap(); + + conn +} + +fn test_config() -> Config { + Config { + gitlab: GitLabConfig { + base_url: "https://gitlab.example.com".to_string(), + token_env_var: "GITLAB_TOKEN".to_string(), + }, + projects: vec![ProjectConfig { + path: "group/project".to_string(), + }], + default_project: None, + sync: SyncConfig::default(), + storage: StorageConfig::default(), + embedding: EmbeddingConfig::default(), + logging: LoggingConfig::default(), + scoring: ScoringConfig::default(), + } +} fn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> Result { let Some(cursor_ts) = cursor.updated_at_cursor else { @@ -47,6 +88,50 @@ fn make_test_issue(id: i64, updated_at: &str) -> GitLabIssue { } } +fn make_issue_with_labels(id: i64, labels: Vec<&str>) -> GitLabIssue { + let mut issue = make_test_issue(id, "2024-06-01T00:00:00.000Z"); + issue.labels = labels.into_iter().map(String::from).collect(); + issue +} + +fn make_issue_with_assignees(id: i64, assignees: Vec<(&str, &str)>) -> GitLabIssue { + let mut issue = make_test_issue(id, "2024-06-01T00:00:00.000Z"); + issue.assignees = assignees + .into_iter() + .enumerate() + .map(|(i, (username, name))| GitLabAuthor { + id: (i + 10) as i64, + username: username.to_string(), + name: name.to_string(), + }) + .collect(); + issue +} + +fn make_issue_with_milestone(id: i64) -> GitLabIssue { + let mut issue = make_test_issue(id, "2024-06-01T00:00:00.000Z"); + issue.milestone = Some(GitLabMilestone { + id: 42, + iid: 5, + project_id: Some(100), + title: "v1.0".to_string(), + description: Some("First release".to_string()), + state: Some("active".to_string()), + due_date: Some("2024-12-31".to_string()), + web_url: Some("https://gitlab.example.com/milestones/5".to_string()), + }); + issue +} + +fn count_rows(conn: &Connection, table: &str) -> i64 { + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .unwrap() +} + +// ─── Cursor Filter Tests ──────────────────────────────────────────────────── + #[test] fn cursor_filter_allows_newer_issues() { let cursor = SyncCursor { @@ -93,3 +178,452 @@ fn cursor_filter_allows_all_when_no_cursor() { let issue = make_test_issue(1, "2020-01-01T00:00:00.000Z"); assert!(passes_cursor_filter(&issue, &cursor).unwrap_or(false)); } + +// ─── parse_timestamp Tests ────────────────────────────────────────────────── + +#[test] +fn parse_timestamp_valid_rfc3339() { + let ts = parse_timestamp("2024-06-15T12:30:00.000Z").unwrap(); + assert_eq!(ts, 1718454600000); +} + +#[test] +fn parse_timestamp_with_timezone_offset() { + let ts = parse_timestamp("2024-06-15T14:30:00.000+02:00").unwrap(); + // +02:00 means UTC time is 12:30, same as above + assert_eq!(ts, 1718454600000); +} + +#[test] +fn parse_timestamp_invalid_format_returns_error() { + let result = parse_timestamp("not-a-date"); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("not-a-date")); +} + +#[test] +fn parse_timestamp_empty_string_returns_error() { + assert!(parse_timestamp("").is_err()); +} + +// ─── passes_cursor_filter_with_ts Tests ───────────────────────────────────── + +#[test] +fn cursor_filter_with_ts_allows_newer() { + let cursor = SyncCursor { + updated_at_cursor: Some(1000), + tie_breaker_id: Some(50), + }; + assert!(passes_cursor_filter_with_ts(60, 2000, &cursor)); +} + +#[test] +fn cursor_filter_with_ts_blocks_older() { + let cursor = SyncCursor { + updated_at_cursor: Some(2000), + tie_breaker_id: Some(50), + }; + assert!(!passes_cursor_filter_with_ts(60, 1000, &cursor)); +} + +#[test] +fn cursor_filter_with_ts_same_timestamp_uses_tie_breaker() { + let cursor = SyncCursor { + updated_at_cursor: Some(1000), + tie_breaker_id: Some(50), + }; + // gitlab_id > cursor tie_breaker => allowed + assert!(passes_cursor_filter_with_ts(51, 1000, &cursor)); + // gitlab_id == cursor tie_breaker => blocked (already processed) + assert!(!passes_cursor_filter_with_ts(50, 1000, &cursor)); + // gitlab_id < cursor tie_breaker => blocked + assert!(!passes_cursor_filter_with_ts(49, 1000, &cursor)); +} + +#[test] +fn cursor_filter_with_ts_no_cursor_allows_all() { + let cursor = SyncCursor::default(); + assert!(passes_cursor_filter_with_ts(1, 0, &cursor)); +} + +// ─── Sync Cursor DB Tests ─────────────────────────────────────────────────── + +#[test] +fn get_sync_cursor_returns_default_when_no_row() { + let conn = setup_test_db(); + let cursor = get_sync_cursor(&conn, 1).unwrap(); + assert!(cursor.updated_at_cursor.is_none()); + assert!(cursor.tie_breaker_id.is_none()); +} + +#[test] +fn update_sync_cursor_creates_and_reads_back() { + let conn = setup_test_db(); + + update_sync_cursor(&conn, 1, 1705312800000, 42).unwrap(); + + let cursor = get_sync_cursor(&conn, 1).unwrap(); + assert_eq!(cursor.updated_at_cursor, Some(1705312800000)); + assert_eq!(cursor.tie_breaker_id, Some(42)); +} + +#[test] +fn update_sync_cursor_upserts_on_conflict() { + let conn = setup_test_db(); + + update_sync_cursor(&conn, 1, 1000, 10).unwrap(); + update_sync_cursor(&conn, 1, 2000, 20).unwrap(); + + let cursor = get_sync_cursor(&conn, 1).unwrap(); + assert_eq!(cursor.updated_at_cursor, Some(2000)); + assert_eq!(cursor.tie_breaker_id, Some(20)); +} + +#[test] +fn sync_cursors_are_project_scoped() { + let conn = setup_test_db(); + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) + VALUES (2, 200, 'other/project', 'https://gitlab.example.com/other/project')", + [], + ) + .unwrap(); + + update_sync_cursor(&conn, 1, 1000, 10).unwrap(); + update_sync_cursor(&conn, 2, 2000, 20).unwrap(); + + let c1 = get_sync_cursor(&conn, 1).unwrap(); + let c2 = get_sync_cursor(&conn, 2).unwrap(); + assert_eq!(c1.updated_at_cursor, Some(1000)); + assert_eq!(c2.updated_at_cursor, Some(2000)); +} + +// ─── process_single_issue Tests ───────────────────────────────────────────── + +#[test] +fn process_single_issue_inserts_basic_issue() { + let conn = setup_test_db(); + let config = test_config(); + let issue = make_test_issue(1001, "2024-06-15T12:00:00.000Z"); + + let labels_created = process_single_issue(&conn, &config, 1, &issue).unwrap(); + assert_eq!(labels_created, 0); + + let (title, state, author): (String, String, String) = conn + .query_row( + "SELECT title, state, author_username FROM issues WHERE gitlab_id = 1001", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(title, "Issue 1001"); + assert_eq!(state, "opened"); + assert_eq!(author, "test"); +} + +#[test] +fn process_single_issue_upserts_on_conflict() { + let conn = setup_test_db(); + let config = test_config(); + + let issue_v1 = make_test_issue(1001, "2024-06-15T12:00:00.000Z"); + process_single_issue(&conn, &config, 1, &issue_v1).unwrap(); + + // Update the issue (same gitlab_id, changed title/state) + let mut issue_v2 = make_test_issue(1001, "2024-06-16T12:00:00.000Z"); + issue_v2.title = "Updated title".to_string(); + issue_v2.state = "closed".to_string(); + process_single_issue(&conn, &config, 1, &issue_v2).unwrap(); + + // Should still be 1 issue (upserted, not duplicated) + assert_eq!(count_rows(&conn, "issues"), 1); + + let (title, state): (String, String) = conn + .query_row( + "SELECT title, state FROM issues WHERE gitlab_id = 1001", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(title, "Updated title"); + assert_eq!(state, "closed"); +} + +#[test] +fn process_single_issue_creates_labels() { + let conn = setup_test_db(); + let config = test_config(); + let issue = make_issue_with_labels(1001, vec!["bug", "critical"]); + + let labels_created = process_single_issue(&conn, &config, 1, &issue).unwrap(); + assert_eq!(labels_created, 2); + + // Verify labels exist + assert_eq!(count_rows(&conn, "labels"), 2); + + // Verify junction table + let label_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM issue_labels il + JOIN issues i ON il.issue_id = i.id + WHERE i.gitlab_id = 1001", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(label_count, 2); +} + +#[test] +fn process_single_issue_label_upsert_idempotent() { + let conn = setup_test_db(); + let config = test_config(); + + let issue1 = make_issue_with_labels(1001, vec!["bug"]); + let created1 = process_single_issue(&conn, &config, 1, &issue1).unwrap(); + assert_eq!(created1, 1); + + // Second issue with same label + let issue2 = make_issue_with_labels(1002, vec!["bug"]); + let created2 = process_single_issue(&conn, &config, 1, &issue2).unwrap(); + assert_eq!(created2, 0); // Label already exists + + // Only 1 label row, but 2 junction rows + assert_eq!(count_rows(&conn, "labels"), 1); + assert_eq!(count_rows(&conn, "issue_labels"), 2); +} + +#[test] +fn process_single_issue_replaces_labels_on_update() { + let conn = setup_test_db(); + let config = test_config(); + + let issue_v1 = make_issue_with_labels(1001, vec!["bug", "critical"]); + process_single_issue(&conn, &config, 1, &issue_v1).unwrap(); + + // Update issue: remove "critical", add "fixed" + let mut issue_v2 = make_issue_with_labels(1001, vec!["bug", "fixed"]); + issue_v2.updated_at = "2024-06-02T00:00:00.000Z".to_string(); + process_single_issue(&conn, &config, 1, &issue_v2).unwrap(); + + // Should now have "bug" and "fixed" linked (not "critical") + let labels: Vec = { + let mut stmt = conn + .prepare( + "SELECT l.name FROM labels l + JOIN issue_labels il ON l.id = il.label_id + JOIN issues i ON il.issue_id = i.id + WHERE i.gitlab_id = 1001 + ORDER BY l.name", + ) + .unwrap(); + stmt.query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert_eq!(labels, vec!["bug", "fixed"]); +} + +#[test] +fn process_single_issue_creates_assignees() { + let conn = setup_test_db(); + let config = test_config(); + let issue = make_issue_with_assignees(1001, vec![("alice", "Alice"), ("bob", "Bob")]); + + process_single_issue(&conn, &config, 1, &issue).unwrap(); + + let assignee_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM issue_assignees ia + JOIN issues i ON ia.issue_id = i.id + WHERE i.gitlab_id = 1001", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(assignee_count, 2); +} + +#[test] +fn process_single_issue_replaces_assignees_on_update() { + let conn = setup_test_db(); + let config = test_config(); + + let issue_v1 = make_issue_with_assignees(1001, vec![("alice", "Alice"), ("bob", "Bob")]); + process_single_issue(&conn, &config, 1, &issue_v1).unwrap(); + + // Update: remove bob, add charlie + let mut issue_v2 = + make_issue_with_assignees(1001, vec![("alice", "Alice"), ("charlie", "Charlie")]); + issue_v2.updated_at = "2024-06-02T00:00:00.000Z".to_string(); + process_single_issue(&conn, &config, 1, &issue_v2).unwrap(); + + let assignees: Vec = { + let mut stmt = conn + .prepare( + "SELECT ia.username FROM issue_assignees ia + JOIN issues i ON ia.issue_id = i.id + WHERE i.gitlab_id = 1001 + ORDER BY ia.username", + ) + .unwrap(); + stmt.query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert_eq!(assignees, vec!["alice", "charlie"]); +} + +#[test] +fn process_single_issue_creates_milestone() { + let conn = setup_test_db(); + let config = test_config(); + let issue = make_issue_with_milestone(1001); + + process_single_issue(&conn, &config, 1, &issue).unwrap(); + + let (title, state): (String, Option) = conn + .query_row( + "SELECT title, state FROM milestones WHERE gitlab_id = 42", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(title, "v1.0"); + assert_eq!(state.as_deref(), Some("active")); + + // Issue should reference the milestone + let ms_id: Option = conn + .query_row( + "SELECT milestone_id FROM issues WHERE gitlab_id = 1001", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(ms_id.is_some()); +} + +#[test] +fn process_single_issue_marks_dirty() { + let conn = setup_test_db(); + let config = test_config(); + let issue = make_test_issue(1001, "2024-06-15T12:00:00.000Z"); + + process_single_issue(&conn, &config, 1, &issue).unwrap(); + + let local_id: i64 = conn + .query_row("SELECT id FROM issues WHERE gitlab_id = 1001", [], |row| { + row.get(0) + }) + .unwrap(); + + let dirty_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'issue' AND source_id = ?", + [local_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(dirty_count, 1); +} + +#[test] +fn process_single_issue_stores_raw_payload() { + let conn = setup_test_db(); + let config = test_config(); + let issue = make_test_issue(1001, "2024-06-15T12:00:00.000Z"); + + process_single_issue(&conn, &config, 1, &issue).unwrap(); + + let payload_id: Option = conn + .query_row( + "SELECT raw_payload_id FROM issues WHERE gitlab_id = 1001", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(payload_id.is_some()); + + // Verify payload row exists + let payload_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM raw_payloads WHERE id = ?", + [payload_id.unwrap()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(payload_count, 1); +} + +// ─── Discussion Sync Queue Tests ──────────────────────────────────────────── + +#[test] +fn get_issues_needing_discussion_sync_detects_updated() { + let conn = setup_test_db(); + let config = test_config(); + + // Insert an issue + let issue = make_test_issue(1001, "2024-06-15T12:00:00.000Z"); + process_single_issue(&conn, &config, 1, &issue).unwrap(); + + // Issue was just upserted, discussions_synced_for_updated_at is NULL, + // so it should need sync + let needing_sync = get_issues_needing_discussion_sync(&conn, 1).unwrap(); + assert_eq!(needing_sync.len(), 1); + assert_eq!(needing_sync[0].iid, 1001); +} + +#[test] +fn get_issues_needing_discussion_sync_skips_already_synced() { + let conn = setup_test_db(); + let config = test_config(); + + let issue = make_test_issue(1001, "2024-06-15T12:00:00.000Z"); + process_single_issue(&conn, &config, 1, &issue).unwrap(); + + // Simulate discussion sync by setting discussions_synced_for_updated_at + let updated_at: i64 = conn + .query_row( + "SELECT updated_at FROM issues WHERE gitlab_id = 1001", + [], + |row| row.get(0), + ) + .unwrap(); + conn.execute( + "UPDATE issues SET discussions_synced_for_updated_at = ? WHERE gitlab_id = 1001", + [updated_at], + ) + .unwrap(); + + let needing_sync = get_issues_needing_discussion_sync(&conn, 1).unwrap(); + assert!(needing_sync.is_empty()); +} + +#[test] +fn get_issues_needing_discussion_sync_is_project_scoped() { + let conn = setup_test_db(); + let config = test_config(); + + // Add a second project + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) + VALUES (2, 200, 'other/project', 'https://gitlab.example.com/other/project')", + [], + ) + .unwrap(); + + let issue1 = make_test_issue(1001, "2024-06-15T12:00:00.000Z"); + process_single_issue(&conn, &config, 1, &issue1).unwrap(); + + let mut issue2 = make_test_issue(1002, "2024-06-15T12:00:00.000Z"); + issue2.project_id = 200; + process_single_issue(&conn, &config, 2, &issue2).unwrap(); + + // Only project 1's issue should appear + let needing_sync = get_issues_needing_discussion_sync(&conn, 1).unwrap(); + assert_eq!(needing_sync.len(), 1); + assert_eq!(needing_sync[0].iid, 1001); +} diff --git a/src/main.rs b/src/main.rs index e1bf79d..9df0a6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,25 +9,25 @@ use tracing_subscriber::util::SubscriberInitExt; use lore::Config; use lore::cli::autocorrect::{self, CorrectionResult}; use lore::cli::commands::{ - IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters, + BriefArgs, IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters, NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, find_lore_tui, - 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_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, + open_issue_in_browser, open_mr_in_browser, parse_trace_path, print_brief_human, + print_brief_json, 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_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_brief, 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}; @@ -211,6 +211,24 @@ async fn main() { handle_related(cli.config.as_deref(), args, robot_mode).await } Some(Commands::Tui(args)) => run_tui(&args, robot_mode), + Some(Commands::Brief { + query, + path, + person, + project, + section_limit, + }) => { + handle_brief( + cli.config.as_deref(), + query, + path, + person, + project, + section_limit, + robot_mode, + ) + .await + } Some(Commands::Explain { entity_type, iid, @@ -746,6 +764,7 @@ fn suggest_similar_command(invalid: &str) -> String { ("who", "who"), ("notes", "notes"), ("note", "notes"), + ("brief", "brief"), ("explain", "explain"), ("drift", "drift"), ("file-history", "file-history"), @@ -2827,6 +2846,17 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box", "--person ", "-p/--project ", "--section-limit "], + "example": "lore --robot brief 'authentication'", + "notes": "Composable capstone: replaces 5+ separate lore calls. Modes: topic (query text), path (--path), person (--person). Each section degrades gracefully.", + "response_schema": { + "ok": "bool", + "data": "BriefResponse{mode,query?,summary,open_issues[{iid,title,state,assignees,labels,updated_at,status_name?,unresolved_count}],active_mrs[{iid,title,state,author,draft,labels,updated_at,unresolved_count}],experts[{username,score,last_activity?}],recent_activity[{timestamp,event_type,entity_ref,summary,actor?}],unresolved_threads[{discussion_id,entity_type,entity_iid,started_by,note_count,last_note_at,first_note_body}],related[{source_type,iid,title,similarity_score}],warnings[string]}", + "meta": {"elapsed_ms": "int", "sections_computed": "[string]"} + } + }, "explain": { "description": "Auto-generate a structured narrative for an issue or MR", "flags": ["", "", "-p/--project "], @@ -2897,6 +2927,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box MR -> issue -> discussion decision chain", "related: Semantic similarity discovery via vector embeddings", + "brief: Situational awareness in one call (open issues, active MRs, experts, threads, warnings)", "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", @@ -3118,6 +3149,35 @@ fn handle_explain( Ok(()) } +async fn handle_brief( + config_override: Option<&str>, + query: Option, + path: Option, + person: Option, + project: Option, + section_limit: usize, + robot_mode: bool, +) -> Result<(), Box> { + let start = std::time::Instant::now(); + let config = Config::load(config_override)?; + let args = BriefArgs { + query, + path, + person, + project, + section_limit, + }; + let response = run_brief(&config, &args).await?; + let elapsed_ms = start.elapsed().as_millis() as u64; + + if robot_mode { + print_brief_json(&response, elapsed_ms); + } else { + print_brief_human(&response); + } + Ok(()) +} + async fn handle_drift( config_override: Option<&str>, entity_type: &str,