diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 63a4cc7..d8ba65a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -11,7 +11,7 @@ {"id":"bd-14q","title":"Epic: Gate 4 - File Decision History (lore file-history)","description":"## Background\n\nGate 4 implements `lore file-history` — answers \"Which MRs touched this file, and why?\" by linking files to MRs via a new mr_file_changes table and resolving rename chains.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 4 (Sections 4.1-4.7).\n\n## Prerequisites\n\n- Gates 1-2 COMPLETE: entity_references populated, resource events fetched\n- Migration 015 exists on disk (commit SHAs + closes watermark) — registered by bd-1oo\n- pending_dependent_fetches has job_type='mr_diffs' in CHECK constraint (migration 011)\n\n## Architecture\n\n- **New table:** mr_file_changes (migration 016) stores file paths per MR\n- **New config:** fetchMrFileChanges (default true) gates the API calls\n- **API source:** GET /projects/:id/merge_requests/:iid/diffs — extract paths only, discard diff content\n- **Rename resolution:** BFS both directions on mr_file_changes WHERE change_type='renamed', bounded at 10 hops\n- **Query:** Join mr_file_changes -> merge_requests, optionally enrich with entity_references and discussions\n\n## Children (Execution Order)\n\n1. **bd-1oo** — Register migration 015 + create migration 016 (mr_file_changes table)\n2. **bd-jec** — Add fetchMrFileChanges config flag\n3. **bd-2yo** — Fetch MR diffs API and populate mr_file_changes\n4. **bd-1yx** — Implement rename chain resolution (BFS algorithm)\n5. **bd-z94** — Implement lore file-history CLI command (human + robot output)\n\n## Gate Completion Criteria\n\n- [ ] mr_file_changes table populated from GitLab diffs API\n- [ ] merge_commit_sha and squash_commit_sha captured in merge_requests (already done in code, needs migration 015 registered)\n- [ ] `lore file-history ` returns MRs ordered by merge/creation date\n- [ ] Output includes: MR title, state, author, change type, discussion count\n- [ ] --discussions shows inline discussion snippets from DiffNotes on the file\n- [ ] Rename chains resolved with bounded hop count (default 10) and cycle detection\n- [ ] --no-follow-renames disables chain resolution\n- [ ] Robot mode JSON includes rename_chain when renames detected\n- [ ] -p required when path in multiple projects (exit 18 Ambiguous)\n","status":"open","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:31:01.094024Z","created_by":"tayloreernisse","updated_at":"2026-02-05T20:56:53.434796Z","compaction_level":0,"original_size":0,"labels":["epic","gate-4","phase-b"],"dependencies":[{"issue_id":"bd-14q","depends_on_id":"bd-1se","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-14q","depends_on_id":"bd-2zl","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-14q8","title":"Split commands.rs into commands/ module (registry + defs)","description":"commands.rs is 807 lines. Split into crates/lore-tui/src/commands/mod.rs (re-exports), commands/registry.rs (CommandRegistry, lookup, status_hints, help_entries, palette_entries, build_registry), and commands/defs.rs (command definitions, KeyCombo, CommandDef struct). Keep public API identical via re-exports. All downstream imports should continue to work unchanged.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T21:24:11.259683Z","created_by":"tayloreernisse","updated_at":"2026-02-18T18:48:18.915386Z","closed_at":"2026-02-18T18:48:18.915341Z","close_reason":"Split commands.rs into commands/ module (defs.rs + registry.rs + mod.rs)","compaction_level":0,"original_size":0,"labels":["TUI"]} {"id":"bd-157","title":"[CP1] Issue transformer with label extraction","description":"Transform GitLab issue payloads to normalized database schema.\n\n## Module\nsrc/gitlab/transformers/issue.rs\n\n## Structs\n\n### NormalizedIssue\n- gitlab_id: i64\n- project_id: i64 (local DB project ID)\n- iid: i64\n- title: String\n- description: Option\n- state: String\n- author_username: String\n- created_at, updated_at, last_seen_at: i64 (ms epoch)\n- web_url: String\n\n### NormalizedLabel (CP1: name-only)\n- project_id: i64\n- name: String\n\n## Functions\n\n### transform_issue(gitlab_issue: &GitLabIssue, local_project_id: i64) -> NormalizedIssue\n- Convert ISO timestamps to ms epoch using iso_to_ms()\n- Set last_seen_at to now_ms()\n- Clone string fields\n\n### extract_labels(gitlab_issue: &GitLabIssue, local_project_id: i64) -> Vec\n- Map labels vec to NormalizedLabel structs\n\nFiles: \n- src/gitlab/transformers/mod.rs\n- src/gitlab/transformers/issue.rs\nTests: tests/issue_transformer_tests.rs\nDone when: Unit tests pass for payload transformation and label extraction","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:47.719562Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.736142Z","closed_at":"2026-01-25T17:02:01.736142Z","deleted_at":"2026-01-25T17:02:01.736129Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-159p","title":"Add get_issue_by_iid and get_mr_by_iid to GitLabClient with wiremock tests","description":"## Background\nSurgical sync needs to fetch a single issue or MR by its project-scoped IID from GitLab REST API during the preflight phase. The existing `GitLabClient` has `paginate_issues` and `paginate_merge_requests` for bulk streaming, but no single-entity fetch by IID. The GitLab v4 API provides `/api/v4/projects/:id/issues/:iid` and `/api/v4/projects/:id/merge_requests/:iid` endpoints that return exactly one entity or 404.\n\nThese methods are used by the surgical preflight (bd-3sez) to validate that requested IIDs actually exist on GitLab before committing to the ingest phase. They must return the full `GitLabIssue` / `GitLabMergeRequest` structs (same as the paginated endpoints return) so they can be passed directly to `process_single_issue` / `process_single_mr`.\n\n## Approach\n\n### Step 1: Add `get_issue_by_iid` method (src/gitlab/client.rs)\n\nAdd after the existing `get_version` method (~line 112):\n\n```rust\npub async fn get_issue_by_iid(\n &self,\n project_id: u64,\n iid: u64,\n) -> Result {\n self.request(&format!(\"/api/v4/projects/{project_id}/issues/{iid}\"))\n .await\n}\n```\n\nThis reuses the existing `request()` method which already handles:\n- Rate limiting (via `RateLimiter`)\n- Retry on 429 (up to `MAX_RETRIES`)\n- 404 → `LoreError::GitLabNotFound { resource }`\n- 401 → `LoreError::GitLabAuthFailed`\n- JSON deserialization into `GitLabIssue`\n\n### Step 2: Add `get_mr_by_iid` method (src/gitlab/client.rs)\n\n```rust\npub async fn get_mr_by_iid(\n &self,\n project_id: u64,\n iid: u64,\n) -> Result {\n self.request(&format!(\"/api/v4/projects/{project_id}/merge_requests/{iid}\"))\n .await\n}\n```\n\n### Step 3: Add wiremock tests (src/gitlab/client_tests.rs or inline #[cfg(test)])\n\nFour tests using the same wiremock pattern as `src/gitlab/graphql_tests.rs`:\n1. `get_issue_by_iid_success` — mock 200 with full GitLabIssue JSON, verify deserialized fields\n2. `get_issue_by_iid_not_found` — mock 404, verify `LoreError::GitLabNotFound`\n3. `get_mr_by_iid_success` — mock 200 with full GitLabMergeRequest JSON, verify deserialized fields\n4. `get_mr_by_iid_not_found` — mock 404, verify `LoreError::GitLabNotFound`\n\n## Acceptance Criteria\n- [ ] `GitLabClient::get_issue_by_iid(project_id, iid)` returns `Result`\n- [ ] `GitLabClient::get_mr_by_iid(project_id, iid)` returns `Result`\n- [ ] 404 response maps to `LoreError::GitLabNotFound`\n- [ ] 401 response maps to `LoreError::GitLabAuthFailed` (inherited from `handle_response`)\n- [ ] Successful responses deserialize into the correct struct types\n- [ ] All 4 wiremock tests pass\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n- MODIFY: src/gitlab/client.rs (add two pub async methods)\n- CREATE: src/gitlab/client_tests.rs (wiremock tests, referenced via `#[cfg(test)] #[path = \"client_tests.rs\"] mod tests;` at bottom of client.rs)\n\n## TDD Anchor\nRED: Write 4 wiremock tests in `src/gitlab/client_tests.rs`:\n\n```rust\nuse super::*;\nuse crate::core::error::LoreError;\nuse wiremock::matchers::{header, method, path};\nuse wiremock::{Mock, MockServer, ResponseTemplate};\n\n#[tokio::test]\nasync fn get_issue_by_iid_success() {\n let server = MockServer::start().await;\n let issue_json = serde_json::json!({\n \"id\": 1001,\n \"iid\": 42,\n \"project_id\": 5,\n \"title\": \"Fix login bug\",\n \"state\": \"opened\",\n \"created_at\": \"2026-01-15T10:00:00Z\",\n \"updated_at\": \"2026-02-01T14:30:00Z\",\n \"author\": { \"id\": 1, \"username\": \"dev1\", \"name\": \"Developer One\", \"avatar_url\": null, \"web_url\": \"https://gitlab.example.com/dev1\" },\n \"web_url\": \"https://gitlab.example.com/group/repo/-/issues/42\",\n \"labels\": [],\n \"milestone\": null,\n \"assignees\": [],\n \"closed_at\": null,\n \"closed_by\": null,\n \"description\": \"Login fails on mobile\"\n });\n\n Mock::given(method(\"GET\"))\n .and(path(\"/api/v4/projects/5/issues/42\"))\n .and(header(\"PRIVATE-TOKEN\", \"test-token\"))\n .respond_with(ResponseTemplate::new(200).set_body_json(&issue_json))\n .mount(&server)\n .await;\n\n let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n let issue = client.get_issue_by_iid(5, 42).await.unwrap();\n assert_eq!(issue.iid, 42);\n assert_eq!(issue.title, \"Fix login bug\");\n}\n\n#[tokio::test]\nasync fn get_issue_by_iid_not_found() {\n let server = MockServer::start().await;\n\n Mock::given(method(\"GET\"))\n .and(path(\"/api/v4/projects/5/issues/999\"))\n .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({\"message\": \"404 Not Found\"})))\n .mount(&server)\n .await;\n\n let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n let err = client.get_issue_by_iid(5, 999).await.unwrap_err();\n assert!(matches!(err, LoreError::GitLabNotFound { .. }));\n}\n\n#[tokio::test]\nasync fn get_mr_by_iid_success() {\n let server = MockServer::start().await;\n let mr_json = serde_json::json!({\n \"id\": 2001,\n \"iid\": 101,\n \"project_id\": 5,\n \"title\": \"Add caching layer\",\n \"state\": \"merged\",\n \"created_at\": \"2026-01-20T09:00:00Z\",\n \"updated_at\": \"2026-02-10T16:00:00Z\",\n \"author\": { \"id\": 2, \"username\": \"dev2\", \"name\": \"Developer Two\", \"avatar_url\": null, \"web_url\": \"https://gitlab.example.com/dev2\" },\n \"web_url\": \"https://gitlab.example.com/group/repo/-/merge_requests/101\",\n \"source_branch\": \"feature/caching\",\n \"target_branch\": \"main\",\n \"draft\": false,\n \"merge_status\": \"can_be_merged\",\n \"labels\": [],\n \"milestone\": null,\n \"assignees\": [],\n \"reviewers\": [],\n \"merged_by\": null,\n \"merged_at\": null,\n \"closed_at\": null,\n \"closed_by\": null,\n \"description\": \"Adds Redis caching\"\n });\n\n Mock::given(method(\"GET\"))\n .and(path(\"/api/v4/projects/5/merge_requests/101\"))\n .and(header(\"PRIVATE-TOKEN\", \"test-token\"))\n .respond_with(ResponseTemplate::new(200).set_body_json(&mr_json))\n .mount(&server)\n .await;\n\n let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n let mr = client.get_mr_by_iid(5, 101).await.unwrap();\n assert_eq!(mr.iid, 101);\n assert_eq!(mr.title, \"Add caching layer\");\n assert_eq!(mr.source_branch, \"feature/caching\");\n}\n\n#[tokio::test]\nasync fn get_mr_by_iid_not_found() {\n let server = MockServer::start().await;\n\n Mock::given(method(\"GET\"))\n .and(path(\"/api/v4/projects/5/merge_requests/999\"))\n .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({\"message\": \"404 Not Found\"})))\n .mount(&server)\n .await;\n\n let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n let err = client.get_mr_by_iid(5, 999).await.unwrap_err();\n assert!(matches!(err, LoreError::GitLabNotFound { .. }));\n}\n```\n\nGREEN: Add the two methods to `GitLabClient`.\nVERIFY: `cargo test get_issue_by_iid && cargo test get_mr_by_iid`\n\n## Edge Cases\n- The `request()` method already handles 429 retries, so no extra retry logic is needed in the new methods.\n- The GitLabIssue/GitLabMergeRequest fixture JSON must include all required (non-Option) fields. Check the struct definitions in `src/gitlab/types.rs` if deserialization fails — the test fixtures above include the minimum required fields based on the struct definitions.\n- The `project_id` parameter is the GitLab-side numeric project ID (not the local SQLite row ID). The caller must resolve this from the local `projects` table's `gitlab_project_id` column.\n\n## Dependency Context\nThis is a leaf/foundation bead with no upstream dependencies. Downstream bead bd-3sez (surgical.rs) calls these methods during preflight to fetch entities by IID before ingesting.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-17T19:12:14.447996Z","created_by":"tayloreernisse","updated_at":"2026-02-17T20:01:59.767219Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-159p","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-159p","depends_on_id":"bd-3sez","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} +{"id":"bd-159p","title":"Add get_issue_by_iid and get_mr_by_iid to GitLabClient with wiremock tests","description":"## Background\nSurgical sync needs to fetch a single issue or MR by its project-scoped IID from GitLab REST API during the preflight phase. The existing `GitLabClient` has `paginate_issues` and `paginate_merge_requests` for bulk streaming, but no single-entity fetch by IID. The GitLab v4 API provides `/api/v4/projects/:id/issues/:iid` and `/api/v4/projects/:id/merge_requests/:iid` endpoints that return exactly one entity or 404.\n\nThese methods are used by the surgical preflight (bd-3sez) to validate that requested IIDs actually exist on GitLab before committing to the ingest phase. They must return the full `GitLabIssue` / `GitLabMergeRequest` structs (same as the paginated endpoints return) so they can be passed directly to `process_single_issue` / `process_single_mr`.\n\n## Approach\n\n### Step 1: Add `get_issue_by_iid` method (src/gitlab/client.rs)\n\nAdd after the existing `get_version` method (~line 112):\n\n```rust\npub async fn get_issue_by_iid(\n &self,\n project_id: u64,\n iid: u64,\n) -> Result {\n self.request(&format!(\"/api/v4/projects/{project_id}/issues/{iid}\"))\n .await\n}\n```\n\nThis reuses the existing `request()` method which already handles:\n- Rate limiting (via `RateLimiter`)\n- Retry on 429 (up to `MAX_RETRIES`)\n- 404 → `LoreError::GitLabNotFound { resource }`\n- 401 → `LoreError::GitLabAuthFailed`\n- JSON deserialization into `GitLabIssue`\n\n### Step 2: Add `get_mr_by_iid` method (src/gitlab/client.rs)\n\n```rust\npub async fn get_mr_by_iid(\n &self,\n project_id: u64,\n iid: u64,\n) -> Result {\n self.request(&format!(\"/api/v4/projects/{project_id}/merge_requests/{iid}\"))\n .await\n}\n```\n\n### Step 3: Add wiremock tests (src/gitlab/client_tests.rs or inline #[cfg(test)])\n\nFour tests using the same wiremock pattern as `src/gitlab/graphql_tests.rs`:\n1. `get_issue_by_iid_success` — mock 200 with full GitLabIssue JSON, verify deserialized fields\n2. `get_issue_by_iid_not_found` — mock 404, verify `LoreError::GitLabNotFound`\n3. `get_mr_by_iid_success` — mock 200 with full GitLabMergeRequest JSON, verify deserialized fields\n4. `get_mr_by_iid_not_found` — mock 404, verify `LoreError::GitLabNotFound`\n\n## Acceptance Criteria\n- [ ] `GitLabClient::get_issue_by_iid(project_id, iid)` returns `Result`\n- [ ] `GitLabClient::get_mr_by_iid(project_id, iid)` returns `Result`\n- [ ] 404 response maps to `LoreError::GitLabNotFound`\n- [ ] 401 response maps to `LoreError::GitLabAuthFailed` (inherited from `handle_response`)\n- [ ] Successful responses deserialize into the correct struct types\n- [ ] All 4 wiremock tests pass\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n- MODIFY: src/gitlab/client.rs (add two pub async methods)\n- CREATE: src/gitlab/client_tests.rs (wiremock tests, referenced via `#[cfg(test)] #[path = \"client_tests.rs\"] mod tests;` at bottom of client.rs)\n\n## TDD Anchor\nRED: Write 4 wiremock tests in `src/gitlab/client_tests.rs`:\n\n```rust\nuse super::*;\nuse crate::core::error::LoreError;\nuse wiremock::matchers::{header, method, path};\nuse wiremock::{Mock, MockServer, ResponseTemplate};\n\n#[tokio::test]\nasync fn get_issue_by_iid_success() {\n let server = MockServer::start().await;\n let issue_json = serde_json::json!({\n \"id\": 1001,\n \"iid\": 42,\n \"project_id\": 5,\n \"title\": \"Fix login bug\",\n \"state\": \"opened\",\n \"created_at\": \"2026-01-15T10:00:00Z\",\n \"updated_at\": \"2026-02-01T14:30:00Z\",\n \"author\": { \"id\": 1, \"username\": \"dev1\", \"name\": \"Developer One\", \"avatar_url\": null, \"web_url\": \"https://gitlab.example.com/dev1\" },\n \"web_url\": \"https://gitlab.example.com/group/repo/-/issues/42\",\n \"labels\": [],\n \"milestone\": null,\n \"assignees\": [],\n \"closed_at\": null,\n \"closed_by\": null,\n \"description\": \"Login fails on mobile\"\n });\n\n Mock::given(method(\"GET\"))\n .and(path(\"/api/v4/projects/5/issues/42\"))\n .and(header(\"PRIVATE-TOKEN\", \"test-token\"))\n .respond_with(ResponseTemplate::new(200).set_body_json(&issue_json))\n .mount(&server)\n .await;\n\n let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n let issue = client.get_issue_by_iid(5, 42).await.unwrap();\n assert_eq!(issue.iid, 42);\n assert_eq!(issue.title, \"Fix login bug\");\n}\n\n#[tokio::test]\nasync fn get_issue_by_iid_not_found() {\n let server = MockServer::start().await;\n\n Mock::given(method(\"GET\"))\n .and(path(\"/api/v4/projects/5/issues/999\"))\n .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({\"message\": \"404 Not Found\"})))\n .mount(&server)\n .await;\n\n let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n let err = client.get_issue_by_iid(5, 999).await.unwrap_err();\n assert!(matches!(err, LoreError::GitLabNotFound { .. }));\n}\n\n#[tokio::test]\nasync fn get_mr_by_iid_success() {\n let server = MockServer::start().await;\n let mr_json = serde_json::json!({\n \"id\": 2001,\n \"iid\": 101,\n \"project_id\": 5,\n \"title\": \"Add caching layer\",\n \"state\": \"merged\",\n \"created_at\": \"2026-01-20T09:00:00Z\",\n \"updated_at\": \"2026-02-10T16:00:00Z\",\n \"author\": { \"id\": 2, \"username\": \"dev2\", \"name\": \"Developer Two\", \"avatar_url\": null, \"web_url\": \"https://gitlab.example.com/dev2\" },\n \"web_url\": \"https://gitlab.example.com/group/repo/-/merge_requests/101\",\n \"source_branch\": \"feature/caching\",\n \"target_branch\": \"main\",\n \"draft\": false,\n \"merge_status\": \"can_be_merged\",\n \"labels\": [],\n \"milestone\": null,\n \"assignees\": [],\n \"reviewers\": [],\n \"merged_by\": null,\n \"merged_at\": null,\n \"closed_at\": null,\n \"closed_by\": null,\n \"description\": \"Adds Redis caching\"\n });\n\n Mock::given(method(\"GET\"))\n .and(path(\"/api/v4/projects/5/merge_requests/101\"))\n .and(header(\"PRIVATE-TOKEN\", \"test-token\"))\n .respond_with(ResponseTemplate::new(200).set_body_json(&mr_json))\n .mount(&server)\n .await;\n\n let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n let mr = client.get_mr_by_iid(5, 101).await.unwrap();\n assert_eq!(mr.iid, 101);\n assert_eq!(mr.title, \"Add caching layer\");\n assert_eq!(mr.source_branch, \"feature/caching\");\n}\n\n#[tokio::test]\nasync fn get_mr_by_iid_not_found() {\n let server = MockServer::start().await;\n\n Mock::given(method(\"GET\"))\n .and(path(\"/api/v4/projects/5/merge_requests/999\"))\n .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({\"message\": \"404 Not Found\"})))\n .mount(&server)\n .await;\n\n let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n let err = client.get_mr_by_iid(5, 999).await.unwrap_err();\n assert!(matches!(err, LoreError::GitLabNotFound { .. }));\n}\n```\n\nGREEN: Add the two methods to `GitLabClient`.\nVERIFY: `cargo test get_issue_by_iid && cargo test get_mr_by_iid`\n\n## Edge Cases\n- The `request()` method already handles 429 retries, so no extra retry logic is needed in the new methods.\n- The GitLabIssue/GitLabMergeRequest fixture JSON must include all required (non-Option) fields. Check the struct definitions in `src/gitlab/types.rs` if deserialization fails — the test fixtures above include the minimum required fields based on the struct definitions.\n- The `project_id` parameter is the GitLab-side numeric project ID (not the local SQLite row ID). The caller must resolve this from the local `projects` table's `gitlab_project_id` column.\n\n## Dependency Context\nThis is a leaf/foundation bead with no upstream dependencies. Downstream bead bd-3sez (surgical.rs) calls these methods during preflight to fetch entities by IID before ingesting.","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-17T19:12:14.447996Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:37:37.640202Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-159p","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-159p","depends_on_id":"bd-3sez","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} {"id":"bd-16m8","title":"OBSERV: Record item counts as span fields in sync stages","description":"## Background\nMetricsLayer (bd-34ek) captures span fields, but the stage functions must actually record item counts INTO their spans. This is the bridge between \"work happened\" and \"MetricsLayer knows about it.\"\n\n## Approach\nIn each stage function, after the work loop completes, record counts into the current span:\n\n### src/ingestion/orchestrator.rs - ingest_project_issues_with_progress() (~line 110)\nAfter issues are fetched and discussions synced:\n```rust\ntracing::Span::current().record(\"items_processed\", result.issues_upserted);\ntracing::Span::current().record(\"items_skipped\", result.issues_skipped);\ntracing::Span::current().record(\"errors\", result.errors);\n```\n\n### src/ingestion/orchestrator.rs - drain_resource_events() (~line 566)\nAfter the drain loop:\n```rust\ntracing::Span::current().record(\"items_processed\", result.fetched);\ntracing::Span::current().record(\"errors\", result.failed);\n```\n\n### src/documents/regenerator.rs - regenerate_dirty_documents() (~line 24)\nAfter the regeneration loop:\n```rust\ntracing::Span::current().record(\"items_processed\", result.regenerated);\ntracing::Span::current().record(\"items_skipped\", result.unchanged);\ntracing::Span::current().record(\"errors\", result.errored);\n```\n\n### src/embedding/pipeline.rs - embed_documents() (~line 36)\nAfter embedding completes:\n```rust\ntracing::Span::current().record(\"items_processed\", result.embedded);\ntracing::Span::current().record(\"items_skipped\", result.skipped);\ntracing::Span::current().record(\"errors\", result.failed);\n```\n\nIMPORTANT: These fields must be declared as tracing::field::Empty in the #[instrument] attribute (done in bd-24j1). You can only record() a field that was declared at span creation. Attempting to record an undeclared field silently does nothing.\n\n## Acceptance Criteria\n- [ ] MetricsLayer captures items_processed for each stage\n- [ ] MetricsLayer captures items_skipped and errors when non-zero\n- [ ] Fields match the span declarations from bd-24j1\n- [ ] extract_timings() returns correct counts in StageTiming\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/ingestion/orchestrator.rs (record counts in ingest + drain functions)\n- src/documents/regenerator.rs (record counts in regenerate)\n- src/embedding/pipeline.rs (record counts in embed)\n\n## TDD Loop\nRED: test_stage_fields_recorded (integration: run pipeline, extract timings, verify counts > 0)\nGREEN: Add Span::current().record() calls at end of each stage\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Span::current() returns a disabled span if no subscriber is registered (e.g., in tests without subscriber setup). record() on disabled span is a no-op. Tests need a subscriber.\n- Field names must exactly match the declaration: \"items_processed\" not \"itemsProcessed\"\n- Recording must happen BEFORE the span closes (before function returns). Place at end of function but before Ok(result).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:32.011236Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:27:38.620645Z","closed_at":"2026-02-04T17:27:38.620601Z","close_reason":"Added tracing::field::Empty declarations and Span::current().record() calls in 4 functions: ingest_project_issues, ingest_project_merge_requests, drain_resource_events, regenerate_dirty_documents, embed_documents","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-16m8","depends_on_id":"bd-24j1","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-16m8","depends_on_id":"bd-34ek","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-16m8","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-17n","title":"OBSERV: Add LoggingConfig to Config struct","description":"## Background\nLoggingConfig centralizes log file settings so users can customize retention and disable file logging. It follows the same #[serde(default)] pattern as SyncConfig (src/core/config.rs:32-78) so existing config.json files continue working with zero changes.\n\n## Approach\nAdd to src/core/config.rs, after the EmbeddingConfig struct (around line 120):\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\n#[serde(default)]\npub struct LoggingConfig {\n /// Directory for log files. Default: None (= XDG data dir + /logs/)\n pub log_dir: Option,\n\n /// Days to retain log files. Default: 30. Set to 0 to disable file logging.\n pub retention_days: u32,\n\n /// Enable JSON log files. Default: true.\n pub file_logging: bool,\n}\n\nimpl Default for LoggingConfig {\n fn default() -> Self {\n Self {\n log_dir: None,\n retention_days: 30,\n file_logging: true,\n }\n }\n}\n```\n\nAdd to the Config struct (src/core/config.rs:123-137), after the embedding field:\n\n```rust\n#[serde(default)]\npub logging: LoggingConfig,\n```\n\nNote: Using impl Default rather than default helper functions (default_retention_days, default_true) because #[serde(default)] on the struct applies Default::default() to the entire struct when the key is missing. This is the same pattern used by SyncConfig.\n\n## Acceptance Criteria\n- [ ] Deserializing {} as LoggingConfig yields retention_days=30, file_logging=true, log_dir=None\n- [ ] Deserializing {\"retention_days\": 7} preserves file_logging=true default\n- [ ] Existing config.json files (no \"logging\" key) deserialize without error\n- [ ] Config struct has .logging field accessible\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/config.rs (add LoggingConfig struct + Default impl, add field to Config)\n\n## TDD Loop\nRED: tests/config_tests.rs (or inline #[cfg(test)] mod):\n - test_logging_config_defaults\n - test_logging_config_partial\nGREEN: Add LoggingConfig struct, Default impl, field on Config\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- retention_days=0 means disable file logging entirely (not \"delete all files\") -- document this in the struct doc comment\n- log_dir with a relative path: should be resolved relative to CWD or treated as absolute? Decision: treat as absolute, document it\n- Missing \"logging\" key in JSON: #[serde(default)] handles this -- the entire LoggingConfig gets Default::default()","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.471193Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:10:22.751969Z","closed_at":"2026-02-04T17:10:22.751921Z","close_reason":"Added LoggingConfig struct with log_dir, retention_days, file_logging fields and serde defaults","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-17n","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-17v","title":"[CP1] gi sync-status enhancement","description":"## Background\n\nThe `gi sync-status` command shows synchronization state: last successful sync time, cursor positions per project/resource, and overall health. This helps users understand when data was last refreshed and diagnose sync issues.\n\n## Approach\n\n### Module: src/cli/commands/sync_status.rs (enhance existing or create)\n\n### Handler Function\n\n```rust\npub async fn handle_sync_status(conn: &Connection) -> Result<()>\n```\n\n### Data to Display\n\n1. **Last sync run**: From `sync_runs` table\n - Started at, completed at, status\n - Issues fetched, discussions fetched\n\n2. **Cursor positions**: From `sync_cursors` table\n - Per (project, resource_type) pair\n - Show updated_at_cursor as human-readable date\n - Show tie_breaker_id (GitLab ID of last processed item)\n\n3. **Overall counts**: Quick summary\n - Total issues, discussions, notes in DB\n\n### Output Format\n\n```\nLast Sync\n─────────\nStatus: completed\nStarted: 2024-01-25 10:30:00\nCompleted: 2024-01-25 10:35:00\nDuration: 5m 23s\n\nCursor Positions\n────────────────\ngroup/project-one (issues):\n Last updated_at: 2024-01-25 10:30:00\n Last GitLab ID: 12345\n\nData Summary\n────────────\nIssues: 1,234\nDiscussions: 5,678\nNotes: 12,345 (excluding 2,000 system)\n```\n\n### Queries\n\n```sql\n-- Last sync run\nSELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1\n\n-- Cursor positions\nSELECT p.path, sc.resource_type, sc.updated_at_cursor, sc.tie_breaker_id\nFROM sync_cursors sc\nJOIN projects p ON sc.project_id = p.id\n\n-- Data summary\nSELECT COUNT(*) FROM issues\nSELECT COUNT(*) FROM discussions\nSELECT COUNT(*), SUM(is_system) FROM notes\n```\n\n## Acceptance Criteria\n\n- [ ] Shows last sync run with status and timing\n- [ ] Shows cursor position per project/resource\n- [ ] Shows total counts for issues, discussions, notes\n- [ ] Handles case where no sync has run yet\n- [ ] Formats timestamps as human-readable local time\n\n## Files\n\n- src/cli/commands/sync_status.rs (create or enhance)\n- src/cli/mod.rs (add SyncStatus variant if new)\n\n## TDD Loop\n\nRED:\n```rust\n#[tokio::test] async fn sync_status_shows_last_run()\n#[tokio::test] async fn sync_status_shows_cursor_positions()\n#[tokio::test] async fn sync_status_handles_no_sync_yet()\n```\n\nGREEN: Implement handler with queries and formatting\n\nVERIFY: `cargo test sync_status`\n\n## Edge Cases\n\n- No sync has ever run - show \"No sync runs recorded\"\n- Sync in progress - show \"Status: running\" with started_at\n- Cursor at epoch 0 - means fresh start, show \"Not started\"\n- Multiple projects - show cursor for each","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.409353Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:03:21.851557Z","closed_at":"2026-01-25T23:03:21.851496Z","close_reason":"Implemented gi sync-status showing last run, cursor positions, and data summary","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-17v","depends_on_id":"bd-208","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} @@ -75,7 +75,7 @@ {"id":"bd-1rdi","title":"WHO: Human terminal output for all 5 modes","description":"## Background\n\nTerminal output for humans. Each mode gets a dedicated print function with consistent styling: bold headers, cyan usernames/refs, dim metadata, table alignment.\n\n## Approach\n\n### Dispatch:\n```rust\npub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {\n match result {\n WhoResult::Expert(r) => print_expert_human(r, project_path),\n WhoResult::Workload(r) => print_workload_human(r),\n WhoResult::Reviews(r) => print_reviews_human(r),\n WhoResult::Active(r) => print_active_human(r, project_path),\n WhoResult::Overlap(r) => print_overlap_human(r, project_path),\n }\n}\n```\n\n### Shared helpers:\n- **print_scope_hint()**: dim \"(aggregated across all projects; use -p to scope)\" when project_path is None. Called by Expert, Active, Overlap.\n- **format_relative_time(ms_epoch)**: \"just now\" / \"N min ago\" / \"N hours ago\" / \"N days ago\" / \"N weeks ago\" / \"N months ago\" — DUPLICATE from list.rs (private there, keep blast radius small)\n- **truncate_str(s, max)**: Unicode-aware, appends \"...\" if truncated\n\n### Mode formats:\n- **Expert**: table with Username(16) / Score(6) / Reviewed(MRs)(12) / Notes(6) / Authored(MRs)(12) / Last Seen. Path match hint line. \"-\" for zero counts.\n- **Workload**: 4 sections (Assigned Issues, Authored MRs, Reviewing MRs, Unresolved Discussions). Canonical refs in cyan. Draft indicator. Per-section truncation.\n- **Reviews**: DiffNote summary line + category table (Category(16) / Count(6) / %(6)). Uncategorized count note.\n- **Active**: Discussion list with entity ref, note count, participants (comma-joined @usernames), project path. Discussion count in header.\n- **Overlap**: table with Username(16) / Role(6) / MRs(7) / Last Seen(12) / MR Refs (first 5, +N overflow). Path match hint.\n\n### All modes: truncation dim hints, empty-state messages, console::style formatting.\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nNo unit tests for print functions (they write to stdout). Verification is manual smoke test.\nVERIFY: `cargo check --all-targets` then manual: `cargo run --release -- who src/features/global-search/`\n\n## Acceptance Criteria\n\n- [ ] cargo check passes (all print functions compile)\n- [ ] Each mode produces readable, aligned terminal output\n- [ ] Scope hint shown when project not specified (Expert, Active, Overlap)\n- [ ] Truncation hints shown when results exceed limit\n- [ ] Empty-state messages for zero results\n\n## Edge Cases\n\n- format_relative_time handles negative diff (\"in the future\")\n- truncate_str is Unicode-aware (.chars().count(), not .len())\n- Workload shows empty message only when ALL 4 sections are empty","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:41:06.190608Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.599783Z","closed_at":"2026-02-08T04:10:29.599749Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1rdi","depends_on_id":"bd-2711","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1rdi","depends_on_id":"bd-b51e","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1rdi","depends_on_id":"bd-m7k1","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1rdi","depends_on_id":"bd-s3rc","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-1rdi","depends_on_id":"bd-zqpf","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-1re","title":"[CP1] gi show issue command","description":"Show issue details with discussions.\n\nFlags:\n- --project=PATH (required if iid is ambiguous across projects)\n\nOutput:\n- Title, project, state, author, dates, labels, URL\n- Description text\n- All discussions with notes (formatted thread view)\n\nHandle ambiguity: If multiple projects have same iid, prompt for --project or show error.\n\nFiles: src/cli/commands/show.ts\nDone when: Issue detail view displays all fields including threaded discussions","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:29.826786Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.153211Z","closed_at":"2026-01-25T15:21:35.153211Z","deleted_at":"2026-01-25T15:21:35.153208Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-1s1","title":"[CP1] Integration tests for issue ingestion","description":"Full integration tests for issue ingestion module.\n\n## Tests (tests/issue_ingestion_tests.rs)\n\n- inserts_issues_into_database\n- creates_labels_from_issue_payloads\n- links_issues_to_labels_via_junction_table\n- removes_stale_label_links_on_resync\n- stores_raw_payload_for_each_issue\n- stores_raw_payload_for_each_discussion\n- updates_cursor_incrementally_per_page\n- resumes_from_cursor_on_subsequent_runs\n- handles_issues_with_no_labels\n- upserts_existing_issues_on_refetch\n- skips_discussion_refetch_for_unchanged_issues\n\n## Test Setup\n- tempfile::TempDir for isolated database\n- wiremock::MockServer for GitLab API\n- Mock handlers returning fixture data\n\nFiles: tests/issue_ingestion_tests.rs\nDone when: All integration tests pass with mocked GitLab","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:12.158586Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.109109Z","closed_at":"2026-01-25T17:02:02.109109Z","deleted_at":"2026-01-25T17:02:02.109105Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-1sc6","title":"Add SurgicalPreflightFailed error variant and foundation visibility changes","description":"## Background\nSurgical sync needs a dedicated error variant for preflight failures (e.g., IID not found on GitLab, project mismatch). The existing `GitLabNotFound` variant maps to exit code 6 and is too generic — it does not convey that the failure occurred during surgical preflight validation. A new `SurgicalPreflightFailed` variant in `LoreError` with a clear Display message and exit code 6 provides actionable feedback to both human and robot consumers.\n\nAdditionally, the `process_single_issue` function in `src/ingestion/issues.rs` and `process_single_mr` + `ProcessMrResult` in `src/ingestion/merge_requests.rs` are currently private. The surgical sync orchestrator (downstream bead bd-3sez) will need to call these from `src/core/surgical.rs`, so they must be raised to `pub(crate)` visibility. No config field is needed for this bead — the surgical sync feature is triggered purely by CLI flags (bead bd-1lja).\n\n## Approach\n\n### Step 1: Add ErrorCode variant (src/core/error.rs, line ~23)\nAdd `SurgicalPreflightFailed` to the `ErrorCode` enum (after `Ambiguous`). Wire it through three impls:\n- `Display`: maps to `\"SURGICAL_PREFLIGHT_FAILED\"`\n- `exit_code()`: maps to `6` (same category as GitLabNotFound — resource-level failure)\n\n### Step 2: Add LoreError variant (src/core/error.rs, after EmbeddingsNotBuilt ~line 155)\n```rust\n#[error(\"Surgical preflight failed for {entity_type} !{iid} in {project}: {reason}\")]\nSurgicalPreflightFailed {\n entity_type: String, // \"issue\" or \"merge_request\"\n iid: u64,\n project: String,\n reason: String,\n},\n```\nWire in `code()` → `ErrorCode::SurgicalPreflightFailed`, `suggestion()` → a helpful message about verifying the IID exists, `actions()` → `[\"lore issues -p \", \"lore mrs -p \"]`.\n\n### Step 3: Raise visibility (src/ingestion/issues.rs, src/ingestion/merge_requests.rs)\n- `process_single_issue` at line 143: `fn` → `pub(crate) fn`\n- `process_single_mr` at line 144: `fn` → `pub(crate) fn`\n- `ProcessMrResult` at line 138: `struct` → `pub(crate) struct` (and its fields)\n\n## Acceptance Criteria\n- [ ] `ErrorCode::SurgicalPreflightFailed` exists with Display `\"SURGICAL_PREFLIGHT_FAILED\"` and exit code 6\n- [ ] `LoreError::SurgicalPreflightFailed { entity_type, iid, project, reason }` exists\n- [ ] `LoreError::SurgicalPreflightFailed { .. }.code()` returns `ErrorCode::SurgicalPreflightFailed`\n- [ ] Display output includes entity_type, iid, project, and reason\n- [ ] `suggestion()` returns a non-None helpful string\n- [ ] `process_single_issue` is `pub(crate)`\n- [ ] `process_single_mr` is `pub(crate)`\n- [ ] `ProcessMrResult` and its fields are `pub(crate)`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] All existing tests pass\n\n## Files\n- MODIFY: src/core/error.rs (add ErrorCode variant, LoreError variant, wire Display/exit_code/code/suggestion/actions)\n- MODIFY: src/ingestion/issues.rs (pub(crate) on process_single_issue)\n- MODIFY: src/ingestion/merge_requests.rs (pub(crate) on process_single_mr, ProcessMrResult + fields)\n\n## TDD Anchor\nRED: Write three tests in a new `#[cfg(test)] mod tests` block at the bottom of `src/core/error.rs`:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn surgical_preflight_failed_display() {\n let err = LoreError::SurgicalPreflightFailed {\n entity_type: \"issue\".to_string(),\n iid: 42,\n project: \"group/repo\".to_string(),\n reason: \"not found on GitLab\".to_string(),\n };\n let msg = err.to_string();\n assert!(msg.contains(\"issue\"), \"missing entity_type: {msg}\");\n assert!(msg.contains(\"42\"), \"missing iid: {msg}\");\n assert!(msg.contains(\"group/repo\"), \"missing project: {msg}\");\n assert!(msg.contains(\"not found on GitLab\"), \"missing reason: {msg}\");\n }\n\n #[test]\n fn surgical_preflight_failed_error_code() {\n let code = ErrorCode::SurgicalPreflightFailed;\n assert_eq!(code.exit_code(), 6);\n }\n\n #[test]\n fn surgical_preflight_failed_code_mapping() {\n let err = LoreError::SurgicalPreflightFailed {\n entity_type: \"merge_request\".to_string(),\n iid: 99,\n project: \"ns/proj\".to_string(),\n reason: \"404\".to_string(),\n };\n assert_eq!(err.code(), ErrorCode::SurgicalPreflightFailed);\n }\n}\n```\n\nGREEN: Add the variant and wire all impls.\nVERIFY: `cargo test surgical_preflight_failed`\n\n## Edge Cases\n- Exit code 6 is shared with `GitLabNotFound` — this is intentional (same semantic category: resource not found). The `ErrorCode` Display string distinguishes them for robot consumers.\n- The `entity_type` field uses strings (\"issue\", \"merge_request\") rather than an enum to avoid over-abstraction for two values.\n- Visibility changes are `pub(crate)`, not `pub` — these are internal implementation details, not public API.\n\n## Dependency Context\nThis is a leaf/foundation bead with no upstream dependencies. Downstream beads bd-1i4i (orchestrator) and bd-3sez (surgical.rs module) depend on this for both the error variant and the pub(crate) visibility of ingestion functions.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-17T19:11:41.476902Z","created_by":"tayloreernisse","updated_at":"2026-02-17T20:01:18.103312Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-1sc6","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-1sc6","depends_on_id":"bd-3sez","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} +{"id":"bd-1sc6","title":"Add SurgicalPreflightFailed error variant and foundation visibility changes","description":"## Background\nSurgical sync needs a dedicated error variant for preflight failures (e.g., IID not found on GitLab, project mismatch). The existing `GitLabNotFound` variant maps to exit code 6 and is too generic — it does not convey that the failure occurred during surgical preflight validation. A new `SurgicalPreflightFailed` variant in `LoreError` with a clear Display message and exit code 6 provides actionable feedback to both human and robot consumers.\n\nAdditionally, the `process_single_issue` function in `src/ingestion/issues.rs` and `process_single_mr` + `ProcessMrResult` in `src/ingestion/merge_requests.rs` are currently private. The surgical sync orchestrator (downstream bead bd-3sez) will need to call these from `src/core/surgical.rs`, so they must be raised to `pub(crate)` visibility. No config field is needed for this bead — the surgical sync feature is triggered purely by CLI flags (bead bd-1lja).\n\n## Approach\n\n### Step 1: Add ErrorCode variant (src/core/error.rs, line ~23)\nAdd `SurgicalPreflightFailed` to the `ErrorCode` enum (after `Ambiguous`). Wire it through three impls:\n- `Display`: maps to `\"SURGICAL_PREFLIGHT_FAILED\"`\n- `exit_code()`: maps to `6` (same category as GitLabNotFound — resource-level failure)\n\n### Step 2: Add LoreError variant (src/core/error.rs, after EmbeddingsNotBuilt ~line 155)\n```rust\n#[error(\"Surgical preflight failed for {entity_type} !{iid} in {project}: {reason}\")]\nSurgicalPreflightFailed {\n entity_type: String, // \"issue\" or \"merge_request\"\n iid: u64,\n project: String,\n reason: String,\n},\n```\nWire in `code()` → `ErrorCode::SurgicalPreflightFailed`, `suggestion()` → a helpful message about verifying the IID exists, `actions()` → `[\"lore issues -p \", \"lore mrs -p \"]`.\n\n### Step 3: Raise visibility (src/ingestion/issues.rs, src/ingestion/merge_requests.rs)\n- `process_single_issue` at line 143: `fn` → `pub(crate) fn`\n- `process_single_mr` at line 144: `fn` → `pub(crate) fn`\n- `ProcessMrResult` at line 138: `struct` → `pub(crate) struct` (and its fields)\n\n## Acceptance Criteria\n- [ ] `ErrorCode::SurgicalPreflightFailed` exists with Display `\"SURGICAL_PREFLIGHT_FAILED\"` and exit code 6\n- [ ] `LoreError::SurgicalPreflightFailed { entity_type, iid, project, reason }` exists\n- [ ] `LoreError::SurgicalPreflightFailed { .. }.code()` returns `ErrorCode::SurgicalPreflightFailed`\n- [ ] Display output includes entity_type, iid, project, and reason\n- [ ] `suggestion()` returns a non-None helpful string\n- [ ] `process_single_issue` is `pub(crate)`\n- [ ] `process_single_mr` is `pub(crate)`\n- [ ] `ProcessMrResult` and its fields are `pub(crate)`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] All existing tests pass\n\n## Files\n- MODIFY: src/core/error.rs (add ErrorCode variant, LoreError variant, wire Display/exit_code/code/suggestion/actions)\n- MODIFY: src/ingestion/issues.rs (pub(crate) on process_single_issue)\n- MODIFY: src/ingestion/merge_requests.rs (pub(crate) on process_single_mr, ProcessMrResult + fields)\n\n## TDD Anchor\nRED: Write three tests in a new `#[cfg(test)] mod tests` block at the bottom of `src/core/error.rs`:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn surgical_preflight_failed_display() {\n let err = LoreError::SurgicalPreflightFailed {\n entity_type: \"issue\".to_string(),\n iid: 42,\n project: \"group/repo\".to_string(),\n reason: \"not found on GitLab\".to_string(),\n };\n let msg = err.to_string();\n assert!(msg.contains(\"issue\"), \"missing entity_type: {msg}\");\n assert!(msg.contains(\"42\"), \"missing iid: {msg}\");\n assert!(msg.contains(\"group/repo\"), \"missing project: {msg}\");\n assert!(msg.contains(\"not found on GitLab\"), \"missing reason: {msg}\");\n }\n\n #[test]\n fn surgical_preflight_failed_error_code() {\n let code = ErrorCode::SurgicalPreflightFailed;\n assert_eq!(code.exit_code(), 6);\n }\n\n #[test]\n fn surgical_preflight_failed_code_mapping() {\n let err = LoreError::SurgicalPreflightFailed {\n entity_type: \"merge_request\".to_string(),\n iid: 99,\n project: \"ns/proj\".to_string(),\n reason: \"404\".to_string(),\n };\n assert_eq!(err.code(), ErrorCode::SurgicalPreflightFailed);\n }\n}\n```\n\nGREEN: Add the variant and wire all impls.\nVERIFY: `cargo test surgical_preflight_failed`\n\n## Edge Cases\n- Exit code 6 is shared with `GitLabNotFound` — this is intentional (same semantic category: resource not found). The `ErrorCode` Display string distinguishes them for robot consumers.\n- The `entity_type` field uses strings (\"issue\", \"merge_request\") rather than an enum to avoid over-abstraction for two values.\n- Visibility changes are `pub(crate)`, not `pub` — these are internal implementation details, not public API.\n\n## Dependency Context\nThis is a leaf/foundation bead with no upstream dependencies. Downstream beads bd-1i4i (orchestrator) and bd-3sez (surgical.rs module) depend on this for both the error variant and the pub(crate) visibility of ingestion functions.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:11:41.476902Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:37:31.179350Z","closed_at":"2026-02-19T05:37:31.178721Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-1sc6","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-1sc6","depends_on_id":"bd-3sez","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} {"id":"bd-1se","title":"Epic: Gate 2 - Cross-Reference Extraction","description":"## Background\nGate 2 builds the entity relationship graph that connects issues, MRs, and discussions. Without cross-references, temporal queries can only show events for individually-matched entities. With them, \"lore timeline auth migration\" can discover that MR !567 closed issue #234, which spawned follow-up issue #299 — even if #299 does not contain the words \"auth migration.\"\n\nThree data sources feed entity_references:\n1. **Structured API (reliable):** GET /projects/:id/merge_requests/:iid/closes_issues\n2. **State events (reliable):** resource_state_events.source_merge_request_id\n3. **System note parsing (best-effort):** \"mentioned in !456\", \"closed by !789\" patterns\n\n## Architecture\n- **entity_references table:** Already created in migration 011 (bd-hu3/bd-czk). Stores source→target relationships with reference_type (closes/mentioned/related) and source_method provenance.\n- **Directionality convention:** source = entity where reference was observed, target = entity being referenced. Consistent across all source_methods.\n- **Unresolved references:** Cross-project refs stored with target_entity_id=NULL, target_project_path populated. Still valuable for timeline narratives.\n- **closes_issues fetch:** Uses generic dependent fetch queue (job_type = mr_closes_issues). One API call per MR.\n- **System note parsing:** Local post-processing after all dependent fetches complete. No API calls. English-only, best-effort.\n\n## Children (Execution Order)\n1. **bd-czk** [CLOSED] — entity_references schema (folded into migration 011)\n2. **bd-8t4** [OPEN] — Extract cross-references from resource_state_events (source_merge_request_id)\n3. **bd-3ia** [OPEN] — Fetch closes_issues API and populate entity_references\n4. **bd-1ji** [OPEN] — Parse system notes for cross-reference patterns\n\n## Gate Completion Criteria\n- [ ] entity_references populated from closes_issues API for all synced MRs\n- [ ] entity_references populated from state events where source_merge_request_id present\n- [ ] System notes parsed for cross-reference patterns (English instances)\n- [ ] Cross-project references stored as unresolved (target_entity_id=NULL)\n- [ ] source_method tracks provenance of each reference\n- [ ] Deduplication: same relationship from multiple sources stored once (UNIQUE constraint)\n- [ ] Timeline JSON includes expansion provenance (via) for expanded entities\n- [ ] Integration test: sync with all three extraction methods, verify entity_references populated\n\n## Dependencies\n- Depends on: Gate 1 (bd-2zl) — event tables and dependent fetch queue\n- Downstream: Gate 3 (bd-ike) depends on entity_references for BFS expansion","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:31:00.981132Z","created_by":"tayloreernisse","updated_at":"2026-02-05T16:08:26.965177Z","closed_at":"2026-02-05T16:08:26.964997Z","close_reason":"All child beads completed: bd-8t4 (state event extraction), bd-3ia (closes_issues API), bd-1ji (system note parsing)","compaction_level":0,"original_size":0,"labels":["epic","gate-2","phase-b"],"dependencies":[{"issue_id":"bd-1se","depends_on_id":"bd-2zl","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-1ser","title":"Implement scope context (global project filter)","description":"## Background\nThe scope context provides a global project filter that flows through all query bridge functions. Users can pin to a specific project set or view all projects. The P keybinding opens a scope picker overlay. Scope is persisted in session state.\n\n## Approach\nCreate crates/lore-tui/src/scope.rs:\n- ScopeContext enum: AllProjects, Pinned(Vec)\n- ProjectInfo: id (i64), path (String)\n- scope_filter_sql(scope: &ScopeContext) -> String: generates WHERE clause fragment\n- All action.rs query functions accept &ScopeContext parameter\n- Scope picker overlay: list of projects with checkbox selection\n- P keybinding toggles scope picker from any screen\n\n## Acceptance Criteria\n- [ ] AllProjects scope returns unfiltered results\n- [ ] Pinned scope filters to specific project IDs\n- [ ] All query functions respect global scope\n- [ ] P keybinding opens scope picker\n- [ ] Scope persisted in session state\n- [ ] Scope change triggers re-query of current screen\n\n## Files\n- CREATE: crates/lore-tui/src/scope.rs\n- MODIFY: crates/lore-tui/src/action.rs (add scope parameter to all queries)\n\n## TDD Anchor\nRED: Write test_scope_filter_sql that creates Pinned scope with 2 projects, asserts generated SQL contains IN (1, 2).\nGREEN: Implement scope_filter_sql.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_scope_filter\n\n## Edge Cases\n- Single-project datasets: scope picker not needed, but should still work\n- Very many projects (>50): scope picker should be scrollable\n- Scope change mid-pagination: reset cursor to first page\n\n## Dependency Context\nUses AppState from \"Implement AppState composition\" task.\nUses session persistence from \"Implement session persistence\" task.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-12T17:03:37.555484Z","created_by":"tayloreernisse","updated_at":"2026-02-19T04:05:23.627190Z","closed_at":"2026-02-19T04:05:23.627135Z","close_reason":"Implemented scope context: scope.rs (SQL helper + project fetcher), state/scope_picker.rs, view/scope_picker.rs. P keybinding + toggle_scope. 629 tests pass.","compaction_level":0,"original_size":0,"labels":["TUI"]} {"id":"bd-1soz","title":"Add half_life_decay() pure function","description":"## Background\nThe decay function is the mathematical core of the scoring model. It must be correct, tested first (TDD RED), and verified independently of any DB or SQL changes.\n\n## Approach\nAdd to who.rs as a private function near the top of the module (before query_expert):\n\n```rust\n/// Exponential half-life decay: R = 2^(-t/h)\n/// Returns 1.0 at elapsed=0, 0.5 at elapsed=half_life, 0.0 if half_life=0.\nfn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {\n let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);\n let hl = f64::from(half_life_days);\n if hl <= 0.0 { return 0.0; }\n 2.0_f64.powf(-days / hl)\n}\n```\n\n## TDD Loop\n\n### RED (write first):\n```rust\n#[test]\nfn test_half_life_decay_math() {\n let hl_180 = 180;\n // At t=0, full retention\n assert!((half_life_decay(0, hl_180) - 1.0).abs() < f64::EPSILON);\n // At t=half_life, exactly 0.5\n let one_hl_ms = 180 * 86_400_000_i64;\n assert!((half_life_decay(one_hl_ms, hl_180) - 0.5).abs() < 1e-10);\n // At t=2*half_life, exactly 0.25\n assert!((half_life_decay(2 * one_hl_ms, hl_180) - 0.25).abs() < 1e-10);\n // Negative elapsed clamped to 0 -> 1.0\n assert!((half_life_decay(-1000, hl_180) - 1.0).abs() < f64::EPSILON);\n // Zero half-life -> 0.0 (div-by-zero guard)\n assert!((half_life_decay(86_400_000, 0)).abs() < f64::EPSILON);\n}\n\n#[test]\nfn test_score_monotonicity_by_age() {\n // For any half-life, older timestamps must never produce higher decay than newer ones.\n // Use deterministic LCG PRNG (no rand dependency).\n let mut seed: u64 = 42;\n let hl = 90_u32;\n for _ in 0..50 {\n seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);\n let newer_ms = (seed % 100_000_000) as i64; // 0-100M ms (~1.15 days max)\n seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);\n let older_ms = newer_ms + (seed % 500_000_000) as i64; // always >= newer\n assert!(\n half_life_decay(older_ms, hl) <= half_life_decay(newer_ms, hl),\n \"Monotonicity violated: decay({older_ms}) > decay({newer_ms})\"\n );\n }\n}\n```\n\n### GREEN: Add the half_life_decay function (3 lines of math).\n### VERIFY: `cargo test -p lore -- test_half_life_decay_math test_score_monotonicity`\n\n## Acceptance Criteria\n- [ ] test_half_life_decay_math passes (4 boundary cases + div-by-zero guard)\n- [ ] test_score_monotonicity_by_age passes (50 random pairs, deterministic seed)\n- [ ] Function is `fn` not `pub fn` (module-private)\n- [ ] No DB dependency — pure function\n\n## Files\n- src/cli/commands/who.rs (function near top, tests in test module)\n\n## Edge Cases\n- Negative elapsed_ms: clamped to 0 via .max(0.0) -> returns 1.0\n- half_life_days = 0: returns 0.0, not NaN/Inf\n- Very large elapsed (10 years): returns very small positive f64, never negative","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:22.913281Z","created_by":"tayloreernisse","updated_at":"2026-02-12T21:07:16.929095Z","closed_at":"2026-02-12T21:07:16.928983Z","close_reason":"Completed: added half_life_decay(elapsed_ms, half_life_days) -> f64 pure function with div-by-zero guard, negative elapsed clamping, and 2 tests (boundary math + monotonicity property). All 585 tests pass.","compaction_level":0,"original_size":0,"labels":["scoring"]} @@ -126,7 +126,7 @@ {"id":"bd-2as","title":"[CP1] Epic: Issue Ingestion","description":"Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.\n\nSuccess Criteria:\n- gi ingest --type=issues fetches all issues (count matches GitLab UI)\n- Labels extracted from issue payloads\n- Issue discussions fetched per-issue\n- Cursor-based sync is resumable\n- Sync tracking records all runs\n- Single-flight lock prevents concurrent runs\n\nReference: docs/prd/checkpoint-1.md","status":"tombstone","priority":1,"issue_type":"task","created_at":"2026-01-25T15:18:44.062057Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.155746Z","closed_at":"2026-01-25T15:21:35.155746Z","deleted_at":"2026-01-25T15:21:35.155744Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2b28","title":"NOTE-0C: Sweep safety guard for partial fetch protection","description":"## Background\nThe sweep pattern (delete notes where last_seen_at < run_seen_at) is correct only when a discussion's notes were fully fetched. If a page fails mid-fetch, the current logic would incorrectly delete valid notes that weren't seen during the incomplete fetch. Especially dangerous for long threads spanning multiple API pages.\n\n## Approach\nAdd a fetch_complete: bool parameter to discussion ingestion functions. Only run sweep when fetch completed successfully:\n\nif fetch_complete {\n sweep_stale_issue_notes(&tx, local_discussion_id, last_seen_at)?;\n} else {\n tracing::warn!(discussion_id = local_discussion_id, \"Skipping stale note sweep due to partial/incomplete fetch\");\n}\n\nDetermining fetch_complete: Look at the existing pagination_error pattern in src/ingestion/discussions.rs lines 148-154. When pagination_error is None (all pages fetched successfully), fetch_complete = true. When pagination_error is Some (network error, rate limit, interruption), fetch_complete = false. The MR path has a similar pattern in src/ingestion/mr_discussions.rs — search for where sweep_stale_discussions (line 539) and sweep_stale_notes (line 551) are called to find the equivalent guard.\n\nThe fetch_complete flag should be threaded from the outer discussion-fetch loop into the per-discussion upsert transaction, NOT as a parameter on sweep itself (sweep always sweeps — the caller decides whether to call it).\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (guard sweep call with fetch_complete, lines 132-146)\n- MODIFY: src/ingestion/mr_discussions.rs (guard sweep call, near line 551 call site)\n\n## TDD Anchor\nRED: test_partial_fetch_does_not_sweep_notes — 5 notes in DB, partial fetch returns 2, assert all 5 still exist.\nGREEN: Add fetch_complete guard around sweep call.\nVERIFY: cargo test partial_fetch_does_not_sweep -- --nocapture\nTests: test_complete_fetch_runs_sweep_normally, test_partial_fetch_then_complete_fetch_cleans_up\n\n## Acceptance Criteria\n- [ ] Sweep only runs when fetch_complete = true\n- [ ] Partial fetch logs a warning (tracing::warn!) but preserves all notes\n- [ ] Second complete fetch correctly sweeps notes deleted on GitLab\n- [ ] Both issue and MR discussion paths support fetch_complete\n- [ ] All 3 tests pass\n\n## Dependency Context\n- Depends on NOTE-0A (bd-3bpk): modifies the sweep call site from NOTE-0A. The sweep functions must exist before this guard can wrap them.\n\n## Edge Cases\n- Rate limit mid-page: pagination_error triggers partial fetch — sweep must be skipped\n- Discussion with 1 page of notes: always fully fetched if no error, sweep runs normally\n- Empty discussion (0 notes returned): still counts as complete fetch — sweep is a no-op anyway","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:44.290790Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.172004Z","closed_at":"2026-02-12T18:13:15.171952Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]} {"id":"bd-2bu","title":"[CP1] GitLab types for issues, discussions, notes","description":"Add Rust types to src/gitlab/types.rs for GitLab API responses.\n\n## Types to Add\n\n### GitLabIssue\n- id: i64 (GitLab global ID)\n- iid: i64 (project-scoped issue number)\n- project_id: i64\n- title: String\n- description: Option\n- state: String (\"opened\" | \"closed\")\n- created_at, updated_at: String (ISO 8601)\n- closed_at: Option\n- author: GitLabAuthor\n- labels: Vec (array of label names - CP1 canonical)\n- web_url: String\nNOTE: labels_details intentionally NOT modeled - varies across GitLab versions\n\n### GitLabAuthor\n- id: i64\n- username: String\n- name: String\n\n### GitLabDiscussion\n- id: String (like \"6a9c1750b37d...\")\n- individual_note: bool\n- notes: Vec\n\n### GitLabNote\n- id: i64\n- note_type: Option (\"DiscussionNote\" | \"DiffNote\" | null)\n- body: String\n- author: GitLabAuthor\n- created_at, updated_at: String (ISO 8601)\n- system: bool\n- resolvable: bool (default false)\n- resolved: bool (default false)\n- resolved_by: Option\n- resolved_at: Option\n- position: Option\n\n### GitLabNotePosition\n- old_path, new_path: Option\n- old_line, new_line: Option\n\nFiles: src/gitlab/types.rs\nTests: Test deserialization with fixtures\nDone when: Types compile and deserialize sample API responses correctly","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:46.922805Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.710057Z","closed_at":"2026-01-25T17:02:01.710057Z","deleted_at":"2026-01-25T17:02:01.710051Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-2cbw","title":"Split who.rs into who/ module (queries + scoring + formatting)","description":"who.rs is 3742 lines — the largest file in the project. Split into src/cli/commands/who/mod.rs (re-exports, CLI arg handling), who/queries.rs (SQL queries, data retrieval), who/scoring.rs (half_life_decay, normalize_query_path, scoring aggregation), who/format.rs (human-readable + robot-mode output formatting). Keep public API identical via re-exports.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T21:24:22.040110Z","created_by":"tayloreernisse","updated_at":"2026-02-12T21:24:45.468981Z","compaction_level":0,"original_size":0,"labels":["CLI"]} +{"id":"bd-2cbw","title":"Split who.rs into who/ module (queries + scoring + formatting)","description":"who.rs is 3742 lines — the largest file in the project. Split into src/cli/commands/who/mod.rs (re-exports, CLI arg handling), who/queries.rs (SQL queries, data retrieval), who/scoring.rs (half_life_decay, normalize_query_path, scoring aggregation), who/format.rs (human-readable + robot-mode output formatting). Keep public API identical via re-exports.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T21:24:22.040110Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:30:15.841587Z","close_reason":"who.rs split completed (per user)","compaction_level":0,"original_size":0,"labels":["CLI"]} {"id":"bd-2cu","title":"[CP1] Discussion ingestion module","description":"Fetch and store discussions/notes for each issue.\n\n## Module\nsrc/ingestion/discussions.rs\n\n## Key Structs\n\n### IngestDiscussionsResult\n- discussions_fetched: usize\n- discussions_upserted: usize\n- notes_upserted: usize\n- system_notes_count: usize\n\n## Main Function\npub async fn ingest_issue_discussions(\n conn, client, config,\n project_id, gitlab_project_id,\n issue_iid, local_issue_id, issue_updated_at\n) -> Result\n\n## Logic\n1. Paginate through all discussions for given issue\n2. For each discussion:\n - Begin transaction\n - Store raw payload (compressed)\n - Transform and upsert discussion record with correct issue FK\n - Get local discussion ID\n - Transform notes from discussion\n - For each note:\n - Store raw payload\n - Upsert note with discussion_id FK\n - Count system notes\n - Commit transaction\n3. After all discussions synced: mark_discussions_synced(conn, local_issue_id, issue_updated_at)\n - UPDATE issues SET discussions_synced_for_updated_at = ? WHERE id = ?\n\n## Invariant\nA rerun MUST NOT refetch discussions for issues whose updated_at has not advanced, even with cursor rewind. The discussions_synced_for_updated_at watermark ensures this.\n\n## Helper Functions\n- upsert_discussion(conn, discussion, payload_id)\n- get_local_discussion_id(conn, project_id, gitlab_id) -> i64\n- upsert_note(conn, discussion_id, note, payload_id)\n- mark_discussions_synced(conn, issue_id, issue_updated_at)\n\nFiles: src/ingestion/discussions.rs\nTests: tests/discussion_watermark_tests.rs\nDone when: Discussions and notes populated with correct FKs, watermark prevents redundant refetch","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:36.703237Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.827880Z","closed_at":"2026-01-25T17:02:01.827880Z","deleted_at":"2026-01-25T17:02:01.827876Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2dk","title":"Implement project resolution for --project filter","description":"## Background\nThe --project filter on search (and other commands) accepts a string that must be resolved to a project_id. Users may type the full path, a partial path, or just the project name. The resolution logic provides cascading match with helpful error messages when ambiguous. This improves ergonomics for multi-project installations.\n\n## Approach\nImplement project resolution function (location TBD — likely `src/core/project.rs` or inline in search filters):\n\n```rust\n/// Resolve a project string to a project_id using cascading match:\n/// 1. Exact match on path_with_namespace\n/// 2. Case-insensitive exact match\n/// 3. Suffix match (only if unambiguous)\n/// 4. Error with available projects list\npub fn resolve_project(conn: &Connection, project_str: &str) -> Result\n```\n\n**SQL queries:**\n```sql\n-- Step 1: exact match\nSELECT id FROM projects WHERE path_with_namespace = ?\n\n-- Step 2: case-insensitive\nSELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?)\n\n-- Step 3: suffix match\nSELECT id, path_with_namespace FROM projects\nWHERE path_with_namespace LIKE '%/' || ?\n OR path_with_namespace = ?\n\n-- Step 4: list all for error message\nSELECT path_with_namespace FROM projects ORDER BY path_with_namespace\n```\n\n**Error format:**\n```\nError: Project 'auth-service' not found.\n\nAvailable projects:\n backend/auth-service\n frontend/auth-service-ui\n infra/auth-proxy\n\nHint: Use the full path, e.g., --project=backend/auth-service\n```\n\nUses `LoreError::Ambiguous` variant for multiple suffix matches.\n\n## Acceptance Criteria\n- [ ] Exact match: \"group/project\" resolves correctly\n- [ ] Case-insensitive: \"Group/Project\" resolves to \"group/project\"\n- [ ] Suffix match: \"project-name\" resolves when only one \"*/project-name\" exists\n- [ ] Ambiguous suffix: error lists matching projects with hint\n- [ ] No match: error lists all available projects with hint\n- [ ] Empty projects table: clear error message\n- [ ] `cargo test project_resolution` passes\n\n## Files\n- `src/core/project.rs` — new file (or add to existing module)\n- `src/core/mod.rs` — add `pub mod project;`\n\n## TDD Loop\nRED: Tests:\n- `test_exact_match` — full path resolves\n- `test_case_insensitive` — mixed case resolves\n- `test_suffix_unambiguous` — short name resolves when unique\n- `test_suffix_ambiguous` — error with list when multiple match\n- `test_no_match` — error with available projects\nGREEN: Implement resolve_project\nVERIFY: `cargo test project_resolution`\n\n## Edge Cases\n- Project path containing special LIKE characters (%, _): unlikely but escape for safety\n- Single project in DB: suffix always unambiguous\n- Project path with multiple slashes: \"org/group/project\" — suffix match on \"project\" works","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:26:13.076571Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:39:17.197735Z","closed_at":"2026-01-30T17:39:17.197552Z","close_reason":"Implemented resolve_project() with cascading match (exact, CI, suffix) + 6 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dk","depends_on_id":"bd-3q2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2dlt","title":"Implement GraphQL client with partial-error handling","description":"## Background\nGitLab's GraphQL endpoint (/api/graphql) uses different auth than REST (Bearer token, not PRIVATE-TOKEN). We need a minimal GraphQL client that handles the GitLab-specific error codes and partial-data responses per GraphQL spec. The client returns a GraphqlQueryResult struct that propagates partial-error metadata end-to-end.\n\n## Approach\nCreate a new file src/gitlab/graphql.rs with GraphqlClient (uses reqwest). Add httpdate crate for Retry-After HTTP-date parsing. Wire into the module tree. Factory on GitLabClient keeps token encapsulated.\n\n## Files\n- src/gitlab/graphql.rs (NEW) — GraphqlClient struct, GraphqlQueryResult, ansi256_from_rgb\n- src/gitlab/mod.rs (add pub mod graphql;)\n- src/gitlab/client.rs (add graphql_client() factory method)\n- Cargo.toml (add httpdate dependency)\n\n## Implementation\n\nGraphqlClient struct:\n Fields: http (reqwest::Client with 30s timeout), base_url (String), token (String)\n Constructor: new(base_url, token) — trims trailing slash from base_url\n \nquery() method:\n - POST to {base_url}/api/graphql\n - Headers: Authorization: Bearer {token}, Content-Type: application/json\n - Body: {\"query\": \"...\", \"variables\": {...}}\n - Returns Result\n\nGraphqlQueryResult struct (pub):\n data: serde_json::Value\n had_partial_errors: bool\n first_partial_error: Option\n\nHTTP status mapping:\n 401 | 403 -> LoreError::GitLabAuthFailed\n 404 -> LoreError::GitLabNotFound { resource: \"GraphQL endpoint\" }\n 429 -> LoreError::GitLabRateLimited { retry_after } (parse Retry-After: try u64 first, then httpdate::parse_http_date, fallback 60)\n Other non-success -> LoreError::Other\n\nGraphQL-level error handling:\n errors array present + data absent/null -> Err(LoreError::Other(\"GraphQL error: {first_msg}\"))\n errors array present + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some(first_msg) })\n No errors + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None })\n No errors + no data -> Err(LoreError::Other(\"missing 'data' field\"))\n\nansi256_from_rgb(r, g, b) -> u8:\n Maps RGB to nearest ANSI 256-color index using 6x6x6 cube (indices 16-231).\n MUST be placed BEFORE #[cfg(test)] module (clippy::items_after_test_module).\n\nFactory in src/gitlab/client.rs:\n pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient {\n crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token)\n }\n\n## Acceptance Criteria\n- [ ] query() sends POST with Bearer auth header\n- [ ] Success: returns GraphqlQueryResult { data, had_partial_errors: false }\n- [ ] Errors-only (no data): returns Err with first error message\n- [ ] Partial data + errors: returns Ok with had_partial_errors: true\n- [ ] 401 -> GitLabAuthFailed\n- [ ] 403 -> GitLabAuthFailed\n- [ ] 404 -> GitLabNotFound\n- [ ] 429 -> GitLabRateLimited (parses Retry-After delta-seconds and HTTP-date, fallback 60)\n- [ ] ansi256_from_rgb: (0,0,0)->16, (255,255,255)->231\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_graphql_query_success, test_graphql_query_with_errors_no_data, test_graphql_auth_uses_bearer, test_graphql_401_maps_to_auth_failed, test_graphql_403_maps_to_auth_failed, test_graphql_404_maps_to_not_found, test_graphql_partial_data_with_errors_returns_data, test_retry_after_http_date_format, test_retry_after_invalid_falls_back_to_60, test_ansi256_from_rgb\n Tests use wiremock or similar mock HTTP server\nGREEN: Implement GraphqlClient, add httpdate to Cargo.toml\nVERIFY: cargo test graphql && cargo test ansi256\n\n## Edge Cases\n- Use r##\"...\"## in tests containing \"#1f75cb\" hex colors (# breaks r#\"...\"#)\n- LoreError::GitLabRateLimited uses u64 not Option — use .unwrap_or(60)\n- httpdate::parse_http_date returns SystemTime — compute duration_since(now) for delta\n- GraphqlQueryResult is NOT Clone — tests must check fields individually","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:41:52.833151Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.417835Z","closed_at":"2026-02-11T07:21:33.417793Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dlt","depends_on_id":"bd-1v8t","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2dlt","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} @@ -155,7 +155,7 @@ {"id":"bd-2mz","title":"Epic: Gate A - Lexical MVP","description":"## Background\nGate A delivers the lexical search MVP — the foundation that works without sqlite-vec or Ollama. It introduces the document layer (documents, document_labels, document_paths), FTS5 indexing, search filters, and the search + stats + generate-docs CLI commands. Gate A is independently shippable — users get working search with FTS5 only.\n\n## Gate A Deliverables\n1. Document generation from issues/MRs/discussions with FTS5 indexing\n2. Lexical search + filters + snippets + lore stats\n\n## Bead Dependencies (execution order)\n1. **bd-3lc** — Rename GiError to LoreError (no deps, enables all subsequent work)\n2. **bd-hrs** — Migration 007 (blocked by bd-3lc)\n3. **bd-221** — Migration 008 FTS5 (blocked by bd-hrs)\n4. **bd-36p** — Document types + extractor module (blocked by bd-3lc)\n5. **bd-18t** — Truncation logic (blocked by bd-36p)\n6. **bd-247** — Issue extraction (blocked by bd-36p, bd-hrs)\n7. **bd-1yz** — MR extraction (blocked by bd-36p, bd-hrs)\n8. **bd-2fp** — Discussion extraction (blocked by bd-36p, bd-hrs, bd-18t)\n9. **bd-1u1** — Document regenerator (blocked by bd-36p, bd-38q, bd-hrs)\n10. **bd-1k1** — FTS5 search (blocked by bd-221)\n11. **bd-3q2** — Search filters (blocked by bd-36p)\n12. **bd-3lu** — Search CLI (blocked by bd-1k1, bd-3q2, bd-36p)\n13. **bd-3qs** — Generate-docs CLI (blocked by bd-1u1, bd-3lu)\n14. **bd-pr1** — Stats CLI (blocked by bd-hrs)\n15. **bd-2dk** — Project resolution (blocked by bd-3lc)\n\n## Acceptance Criteria\n- [ ] `lore search \"query\"` returns FTS5 results with snippets\n- [ ] `lore search --type issue --label bug \"query\"` filters correctly\n- [ ] `lore generate-docs` creates documents from all entities\n- [ ] `lore generate-docs --full` regenerates everything\n- [ ] `lore stats` shows document/FTS/queue counts\n- [ ] `lore stats --check` verifies FTS consistency\n- [ ] No sqlite-vec dependency in Gate A","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-30T15:25:09.721108Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:54:44.243610Z","closed_at":"2026-01-30T17:54:44.243562Z","close_reason":"All Gate A sub-beads complete. Lexical MVP delivered: document extraction (issue/MR/discussion), FTS5 indexing, search with filters/snippets/RRF, generate-docs CLI, stats CLI with integrity check/repair.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2mz","depends_on_id":"bd-3lu","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2mz","depends_on_id":"bd-3qs","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2mz","depends_on_id":"bd-pr1","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2n4","title":"Implement trace query: file -> MR -> issue -> discussion chain","description":"## Background\n\nThe trace query builds a chain from file path -> MRs -> issues -> discussions, combining data from mr_file_changes (Gate 4), entity_references (Gate 2), and the existing discussions/notes tables. This is the backend for the trace CLI command.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.4 (Query Flow Tier 1).\n\n## Codebase Context\n\n- entity_references table (migration 011): source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method\n- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type\n- discussions table: issue_id, merge_request_id\n- notes table: discussion_id, author_username, body, created_at, is_system, position_new_path (for DiffNotes)\n- merge_requests table: iid, title, state, author_username, web_url, merged_at, updated_at\n- issues table: iid, title, state, web_url\n- resolve_rename_chain() from bd-1yx (src/core/file_history.rs) provides multi-path matching\n- reference_type values: 'closes', 'mentioned', 'related'\n\n## Approach\n\nCreate `src/core/trace.rs`:\n\n```rust\nuse rusqlite::Connection;\nuse crate::core::file_history::resolve_rename_chain;\nuse crate::core::error::Result;\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceChain {\n pub merge_request: TraceMr,\n pub issues: Vec,\n pub discussions: Vec,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceMr {\n pub iid: i64,\n pub title: String,\n pub state: String,\n pub author_username: String,\n pub web_url: Option,\n pub merged_at: Option,\n pub merge_commit_sha: Option,\n pub file_change_type: String,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceIssue {\n pub iid: i64,\n pub title: String,\n pub state: String,\n pub web_url: Option,\n pub reference_type: String, // \"closes\", \"mentioned\", \"related\"\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceDiscussion {\n pub author_username: String,\n pub body_snippet: String, // truncated to 500 chars\n pub created_at: i64,\n pub is_diff_note: bool, // true if position_new_path matched\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceResult {\n pub path: String,\n pub resolved_paths: Vec,\n pub chains: Vec,\n}\n\npub fn run_trace(\n conn: &Connection,\n project_id: i64,\n path: &str,\n follow_renames: bool,\n include_discussions: bool,\n limit: usize,\n) -> Result {\n // 1. Resolve rename chain (unless !follow_renames)\n let paths = if follow_renames {\n resolve_rename_chain(conn, project_id, path, 10)?\n } else {\n vec![path.to_string()]\n };\n\n // 2. Find MRs via mr_file_changes for all resolved paths\n // Dynamic IN-clause for path set\n // 3. For each MR, find linked issues via entity_references\n // 4. If include_discussions, fetch DiffNote discussions on traced file\n // 5. Order chains by COALESCE(merged_at, updated_at) DESC, apply limit\n}\n```\n\n### SQL for step 2 (find MRs):\n\nBuild dynamic IN-clause placeholders for the resolved path set:\n```sql\nSELECT DISTINCT mr.id, mr.iid, mr.title, mr.state, mr.author_username,\n mr.web_url, mr.merged_at, mr.updated_at, mr.merge_commit_sha,\n mfc.change_type\nFROM mr_file_changes mfc\nJOIN merge_requests mr ON mr.id = mfc.merge_request_id\nWHERE mfc.project_id = ?1\n AND (mfc.new_path IN (...placeholders...) OR mfc.old_path IN (...placeholders...))\nORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC\nLIMIT ?N\n```\n\n### SQL for step 3 (linked issues):\n```sql\nSELECT i.iid, i.title, i.state, i.web_url, er.reference_type\nFROM entity_references er\nJOIN issues i ON i.id = er.target_entity_id\nWHERE er.source_entity_type = 'merge_request'\n AND er.source_entity_id = ?1\n AND er.target_entity_type = 'issue'\n```\n\n### SQL for step 4 (DiffNote discussions):\n```sql\nSELECT n.author_username, n.body, n.created_at, n.position_new_path\nFROM notes n\nJOIN discussions d ON d.id = n.discussion_id\nWHERE d.merge_request_id = ?1\n AND n.position_new_path IN (...placeholders...)\n AND n.is_system = 0\nORDER BY n.created_at ASC\n```\n\nRegister in `src/core/mod.rs`: `pub mod trace;`\n\n## Acceptance Criteria\n\n- [ ] run_trace() returns chains ordered by COALESCE(merged_at, updated_at) DESC\n- [ ] Rename-aware: uses all paths from resolve_rename_chain\n- [ ] Issues linked via entity_references (closes, mentioned, related)\n- [ ] DiffNote discussions correctly filtered to traced file paths via position_new_path\n- [ ] Discussion body_snippet truncated to 500 chars\n- [ ] Empty result (file not in any MR) returns TraceResult with empty chains\n- [ ] Limit applies to number of chains (MRs), not total discussions\n- [ ] Module registered in src/core/mod.rs as `pub mod trace;`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/trace.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod trace;`)\n\n## TDD Loop\n\nRED:\n- `test_trace_empty_file` — unknown file returns empty chains\n- `test_trace_finds_mr` — file in mr_file_changes returns chain with correct MR\n- `test_trace_follows_renames` — renamed file finds historical MRs\n- `test_trace_links_issues` — MR with entity_references shows linked issues\n- `test_trace_limits_chains` — limit=1 returns at most 1 chain\n- `test_trace_no_follow_renames` — follow_renames=false only matches literal path\n\nTests need in-memory DB with migrations applied through 016 + test fixtures for mr_file_changes, entity_references, discussions, notes.\n\nGREEN: Implement SQL queries and chain assembly.\n\nVERIFY: `cargo test --lib -- trace`\n\n## Edge Cases\n\n- MR with no linked issues: chain has empty issues vec\n- Same issue linked from multiple MRs: appears in each chain independently\n- DiffNote on old_path (before rename): captured via resolved path set\n- include_discussions=false: skip DiffNote query for performance\n- Null merged_at: falls back to updated_at for ordering\n- Dynamic IN-clause: use rusqlite::params_from_iter for parameterized queries\n","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:32.738743Z","created_by":"tayloreernisse","updated_at":"2026-02-17T19:08:40.226759Z","compaction_level":0,"original_size":0,"labels":["gate-5","phase-b","query"],"dependencies":[{"issue_id":"bd-2n4","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-2n4","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-2n4","depends_on_id":"bd-z94","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} {"id":"bd-2nb","title":"[CP1] Issue ingestion module","description":"Fetch and store issues with cursor-based incremental sync.\n\nImplement ingestIssues(options) → { fetched, upserted, labelsCreated }\n\nLogic:\n1. Get current cursor from sync_cursors\n2. Paginate through issues updated after cursor\n3. Apply local filtering for tuple cursor semantics\n4. For each issue:\n - Store raw payload (compressed)\n - Upsert issue record\n - Extract and upsert labels\n - Link issue to labels via junction\n5. Update cursor after each page commit\n\nFiles: src/ingestion/issues.ts\nTests: tests/integration/issue-ingestion.test.ts\nDone when: Issues, labels, issue_labels populated correctly with resumable cursor","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:50.701180Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.154318Z","closed_at":"2026-01-25T15:21:35.154318Z","deleted_at":"2026-01-25T15:21:35.154316Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-2nfs","title":"Implement snapshot test infrastructure + terminal compat matrix","description":"## Background\nSnapshot tests ensure deterministic rendering using FakeClock and ftui's test backend. They capture rendered TUI output as styled text and compare against golden files, catching visual regressions without a real terminal. The terminal compatibility matrix is a separate documentation artifact, not an automated test.\n\n## Approach\n\n### Snapshot Infrastructure\n\n**Test Backend**: Use `ftui_harness::TestBackend` (or equivalent from ftui-harness crate) which captures rendered output as a Buffer without needing a real terminal. If ftui-harness is not available, create a minimal TestBackend that implements ftui's backend trait and stores cells in a `Vec>`.\n\n**Deterministic Rendering**:\n- Inject FakeClock (from bd-2lg6) to freeze all relative time computations (\"2 hours ago\" always renders the same)\n- Fix terminal size to 120x40 for all snapshot tests\n- Use synthetic DB fixture with known data (same fixture pattern as parity tests)\n\n**Snapshot Capture Flow**:\n```rust\nfn capture_snapshot(app: &LoreApp, size: (u16, u16)) -> String {\n let backend = TestBackend::new(size.0, size.1);\n // Render app.view() to backend\n // Convert buffer cells to plain text with ANSI annotations\n // Return as String\n}\n```\n\n**Golden File Management**:\n- Golden files stored in `crates/lore-tui/tests/snapshots/` as `.snap` files\n- Naming: `{test_name}.snap` (e.g., `dashboard_default.snap`)\n- Update mode: set env var `UPDATE_SNAPSHOTS=1` to overwrite golden files instead of comparing\n- Use `insta` crate (or manual file comparison) for snapshot assertion\n\n**Fixture Data** (synthetic, deterministic):\n- 50 issues (mix of opened/closed/locked states, various labels)\n- 25 MRs (mix of opened/merged/closed/draft)\n- 100 discussions with notes\n- Known timestamps relative to FakeClock's frozen time\n\n### Snapshot Tests\n\nEach test:\n1. Creates in-memory DB with fixture data\n2. Creates LoreApp with FakeClock frozen at 2026-01-15T12:00:00Z\n3. Sets initial screen state\n4. Renders via TestBackend at 120x40\n5. Compares output against golden file\n\nTests to implement:\n- `test_dashboard_snapshot`: Dashboard screen with fixture counts and recent activity\n- `test_issue_list_snapshot`: Issue list with default sort, showing state badges and relative times\n- `test_issue_detail_snapshot`: Single issue detail with description and discussion thread\n- `test_mr_list_snapshot`: MR list showing draft indicators and review status\n- `test_search_results_snapshot`: Search results with highlighted matches\n- `test_empty_state_snapshot`: Dashboard with empty DB (zero issues/MRs)\n\n### Terminal Compatibility Matrix (Documentation)\n\nThis is a manual verification checklist, NOT an automated test. Document results in `crates/lore-tui/TERMINAL_COMPAT.md`:\n\n| Feature | iTerm2 | tmux | Alacritty | kitty |\n|---------|--------|------|-----------|-------|\n| True color (RGB) | | | | |\n| Unicode width (CJK) | | | | |\n| Box-drawing chars | | | | |\n| Bold/italic/underline | | | | |\n| Mouse events | | | | |\n| Resize handling | | | | |\n| Alt screen | | | | |\n\nFill in during manual QA, not during automated test implementation.\n\n## Acceptance Criteria\n- [ ] At least 6 snapshot tests pass with golden files committed to repo\n- [ ] All snapshots use FakeClock frozen at 2026-01-15T12:00:00Z\n- [ ] All snapshots render at fixed 120x40 terminal size\n- [ ] Dashboard snapshot matches golden file (deterministic)\n- [ ] Issue list snapshot matches golden file (deterministic)\n- [ ] Empty state snapshot matches golden file\n- [ ] UPDATE_SNAPSHOTS=1 env var overwrites golden files for updates\n- [ ] Golden files are plain text (diffable in version control)\n- [ ] TERMINAL_COMPAT.md template created (to be filled during manual QA)\n\n## Files\n- CREATE: crates/lore-tui/tests/snapshot_tests.rs\n- CREATE: crates/lore-tui/tests/snapshots/ (directory for golden files)\n- CREATE: crates/lore-tui/tests/snapshots/dashboard_default.snap\n- CREATE: crates/lore-tui/tests/snapshots/issue_list_default.snap\n- CREATE: crates/lore-tui/tests/snapshots/issue_detail.snap\n- CREATE: crates/lore-tui/tests/snapshots/mr_list_default.snap\n- CREATE: crates/lore-tui/tests/snapshots/search_results.snap\n- CREATE: crates/lore-tui/tests/snapshots/empty_state.snap\n- CREATE: crates/lore-tui/TERMINAL_COMPAT.md (template)\n\n## TDD Anchor\nRED: Write `test_dashboard_snapshot` that creates LoreApp with FakeClock and fixture DB, renders Dashboard at 120x40, asserts output matches `snapshots/dashboard_default.snap`. Fails because golden file does not exist yet.\nGREEN: Render the Dashboard, run with UPDATE_SNAPSHOTS=1 to generate golden file, then run normally to verify match.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml snapshot\n\n## Edge Cases\n- Golden file encoding: always UTF-8, normalize line endings to LF\n- FakeClock must be injected into all components that compute relative time (e.g., \"2 hours ago\")\n- Snapshot diffs on CI: print a clear diff showing expected vs actual when mismatch occurs\n- Fixture data must NOT include non-deterministic values (random IDs, current timestamps)\n- If ftui-harness API changes, TestBackend shim may need updating\n\n## Dependency Context\n- Uses FakeClock from bd-2lg6 (Implement Clock trait)\n- Uses all screen views from Phase 2 (Dashboard, Issue List, MR List, Detail views)\n- Uses TestBackend from ftui-harness crate (or custom implementation)\n- Depends on bd-3h00 (session persistence) per phase ordering — screens must be complete before snapshotting\n- Downstream: bd-nu0d (fuzz tests) and bd-3fjk (race tests) depend on this infrastructure","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:03:54.220114Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:38.126586Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2nfs","depends_on_id":"bd-1b6k","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2nfs","depends_on_id":"bd-3h00","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} +{"id":"bd-2nfs","title":"Implement snapshot test infrastructure + terminal compat matrix","description":"## Background\nSnapshot tests ensure deterministic rendering using FakeClock and ftui's test backend. They capture rendered TUI output as styled text and compare against golden files, catching visual regressions without a real terminal. The terminal compatibility matrix is a separate documentation artifact, not an automated test.\n\n## Approach\n\n### Snapshot Infrastructure\n\n**Test Backend**: Use `ftui_harness::TestBackend` (or equivalent from ftui-harness crate) which captures rendered output as a Buffer without needing a real terminal. If ftui-harness is not available, create a minimal TestBackend that implements ftui's backend trait and stores cells in a `Vec>`.\n\n**Deterministic Rendering**:\n- Inject FakeClock (from bd-2lg6) to freeze all relative time computations (\"2 hours ago\" always renders the same)\n- Fix terminal size to 120x40 for all snapshot tests\n- Use synthetic DB fixture with known data (same fixture pattern as parity tests)\n\n**Snapshot Capture Flow**:\n```rust\nfn capture_snapshot(app: &LoreApp, size: (u16, u16)) -> String {\n let backend = TestBackend::new(size.0, size.1);\n // Render app.view() to backend\n // Convert buffer cells to plain text with ANSI annotations\n // Return as String\n}\n```\n\n**Golden File Management**:\n- Golden files stored in `crates/lore-tui/tests/snapshots/` as `.snap` files\n- Naming: `{test_name}.snap` (e.g., `dashboard_default.snap`)\n- Update mode: set env var `UPDATE_SNAPSHOTS=1` to overwrite golden files instead of comparing\n- Use `insta` crate (or manual file comparison) for snapshot assertion\n\n**Fixture Data** (synthetic, deterministic):\n- 50 issues (mix of opened/closed/locked states, various labels)\n- 25 MRs (mix of opened/merged/closed/draft)\n- 100 discussions with notes\n- Known timestamps relative to FakeClock's frozen time\n\n### Snapshot Tests\n\nEach test:\n1. Creates in-memory DB with fixture data\n2. Creates LoreApp with FakeClock frozen at 2026-01-15T12:00:00Z\n3. Sets initial screen state\n4. Renders via TestBackend at 120x40\n5. Compares output against golden file\n\nTests to implement:\n- `test_dashboard_snapshot`: Dashboard screen with fixture counts and recent activity\n- `test_issue_list_snapshot`: Issue list with default sort, showing state badges and relative times\n- `test_issue_detail_snapshot`: Single issue detail with description and discussion thread\n- `test_mr_list_snapshot`: MR list showing draft indicators and review status\n- `test_search_results_snapshot`: Search results with highlighted matches\n- `test_empty_state_snapshot`: Dashboard with empty DB (zero issues/MRs)\n\n### Terminal Compatibility Matrix (Documentation)\n\nThis is a manual verification checklist, NOT an automated test. Document results in `crates/lore-tui/TERMINAL_COMPAT.md`:\n\n| Feature | iTerm2 | tmux | Alacritty | kitty |\n|---------|--------|------|-----------|-------|\n| True color (RGB) | | | | |\n| Unicode width (CJK) | | | | |\n| Box-drawing chars | | | | |\n| Bold/italic/underline | | | | |\n| Mouse events | | | | |\n| Resize handling | | | | |\n| Alt screen | | | | |\n\nFill in during manual QA, not during automated test implementation.\n\n## Acceptance Criteria\n- [ ] At least 6 snapshot tests pass with golden files committed to repo\n- [ ] All snapshots use FakeClock frozen at 2026-01-15T12:00:00Z\n- [ ] All snapshots render at fixed 120x40 terminal size\n- [ ] Dashboard snapshot matches golden file (deterministic)\n- [ ] Issue list snapshot matches golden file (deterministic)\n- [ ] Empty state snapshot matches golden file\n- [ ] UPDATE_SNAPSHOTS=1 env var overwrites golden files for updates\n- [ ] Golden files are plain text (diffable in version control)\n- [ ] TERMINAL_COMPAT.md template created (to be filled during manual QA)\n\n## Files\n- CREATE: crates/lore-tui/tests/snapshot_tests.rs\n- CREATE: crates/lore-tui/tests/snapshots/ (directory for golden files)\n- CREATE: crates/lore-tui/tests/snapshots/dashboard_default.snap\n- CREATE: crates/lore-tui/tests/snapshots/issue_list_default.snap\n- CREATE: crates/lore-tui/tests/snapshots/issue_detail.snap\n- CREATE: crates/lore-tui/tests/snapshots/mr_list_default.snap\n- CREATE: crates/lore-tui/tests/snapshots/search_results.snap\n- CREATE: crates/lore-tui/tests/snapshots/empty_state.snap\n- CREATE: crates/lore-tui/TERMINAL_COMPAT.md (template)\n\n## TDD Anchor\nRED: Write `test_dashboard_snapshot` that creates LoreApp with FakeClock and fixture DB, renders Dashboard at 120x40, asserts output matches `snapshots/dashboard_default.snap`. Fails because golden file does not exist yet.\nGREEN: Render the Dashboard, run with UPDATE_SNAPSHOTS=1 to generate golden file, then run normally to verify match.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml snapshot\n\n## Edge Cases\n- Golden file encoding: always UTF-8, normalize line endings to LF\n- FakeClock must be injected into all components that compute relative time (e.g., \"2 hours ago\")\n- Snapshot diffs on CI: print a clear diff showing expected vs actual when mismatch occurs\n- Fixture data must NOT include non-deterministic values (random IDs, current timestamps)\n- If ftui-harness API changes, TestBackend shim may need updating\n\n## Dependency Context\n- Uses FakeClock from bd-2lg6 (Implement Clock trait)\n- Uses all screen views from Phase 2 (Dashboard, Issue List, MR List, Detail views)\n- Uses TestBackend from ftui-harness crate (or custom implementation)\n- Depends on bd-3h00 (session persistence) per phase ordering — screens must be complete before snapshotting\n- Downstream: bd-nu0d (fuzz tests) and bd-3fjk (race tests) depend on this infrastructure","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:03:54.220114Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:37:55.058498Z","closed_at":"2026-02-19T05:37:55.058110Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2nfs","depends_on_id":"bd-1b6k","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2nfs","depends_on_id":"bd-3h00","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2ni","title":"OBSERV Epic: Phase 2 - Spans + Correlation IDs","description":"Add tracing spans to all sync stages and generate UUID-based run_id for correlation. Every log line within a sync run includes run_id in JSON span context. Nested spans produce correct parent-child chains.\n\nDepends on: Phase 1 (subscriber must support span recording)\nUnblocks: Phase 3 (metrics), Phase 5 (rate limit logging)\n\nFiles: src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/ingestion/orchestrator.rs, src/documents/regenerator.rs, src/embedding/pipeline.rs, src/main.rs\n\nAcceptance criteria (PRD Section 6.2):\n- Every log line includes run_id in JSON span context\n- Nested spans produce chain: fetch_pages includes parent ingest_issues span\n- run_id is 8-char hex (truncated UUIDv4)\n- Spans visible in -vv stderr output","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-04T15:53:08.935218Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:19:38.721297Z","closed_at":"2026-02-04T17:19:38.721241Z","close_reason":"Phase 2 complete: run_id correlation IDs generated at sync/ingest entry, root spans with .instrument() for async, #[instrument] on 5 key pipeline functions","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-2ni","depends_on_id":"bd-2nx","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2no","title":"Write integration tests","description":"## Background\nIntegration tests verify that modules work together with a real SQLite database. They test FTS search (stemming, empty results), embedding storage (sqlite-vec ops), hybrid search (combined retrieval), and sync orchestration (full pipeline). Each test creates a fresh in-memory DB with migrations applied.\n\n## Approach\nCreate integration test files in `tests/`:\n\n**1. tests/fts_search.rs:**\n- Create DB, apply migrations 001-008\n- Insert test documents via SQL\n- Verify FTS5 triggers fired (documents_fts has matching count)\n- Search with various queries: stemming, prefix, empty, special chars\n- Verify result ranking (BM25 ordering)\n- Verify snippet generation\n\n**2. tests/embedding.rs:**\n- Create DB, apply migrations 001-009 (requires sqlite-vec)\n- Insert test documents + embeddings with known vectors\n- Verify KNN search returns nearest neighbors\n- Verify chunk deduplication\n- Verify orphan cleanup trigger (delete document -> embeddings gone)\n\n**3. tests/hybrid_search.rs:**\n- Create DB, apply all migrations\n- Insert documents + embeddings\n- Test all three modes: lexical, semantic, hybrid\n- Verify RRF ranking produces expected order\n- Test graceful degradation (no embeddings -> FTS fallback)\n- Test adaptive recall with filters\n\n**4. tests/sync.rs:**\n- Test sync orchestration with mock/stub GitLab responses\n- Verify pipeline stages execute in order\n- Verify lock acquisition/release\n- Verify --no-embed and --no-docs flags\n\n**Test fixtures:**\n- Deterministic embedding vectors (no Ollama required): e.g., [1.0, 0.0, 0.0, ...] for doc1, [0.0, 1.0, 0.0, ...] for doc2\n- Known documents with predictable search results\n- Fixed timestamps for reproducibility\n\n## Acceptance Criteria\n- [ ] FTS search tests pass (stemming, prefix, empty, special chars)\n- [ ] Embedding tests pass (KNN, dedup, orphan cleanup)\n- [ ] Hybrid search tests pass (all 3 modes, graceful degradation)\n- [ ] Sync tests pass (pipeline orchestration)\n- [ ] All tests use in-memory DB (no file I/O)\n- [ ] No external dependencies (no Ollama, no GitLab) — use fixtures/stubs\n- [ ] `cargo test --test fts_search --test embedding --test hybrid_search --test sync` passes\n\n## Files\n- `tests/fts_search.rs` — new file\n- `tests/embedding.rs` — new file\n- `tests/hybrid_search.rs` — new file\n- `tests/sync.rs` — new file\n- `tests/fixtures/` — optional: test helper functions (shared DB setup)\n\n## TDD Loop\nThese ARE integration tests — they verify the combined behavior of multiple beads.\nVERIFY: `cargo test --test fts_search && cargo test --test embedding && cargo test --test hybrid_search && cargo test --test sync`\n\n## Edge Cases\n- sqlite-vec not available: embedding tests should skip gracefully (or require feature flag)\n- In-memory DB with WAL mode: may behave differently than file DB — test both if critical\n- Concurrent test execution: each test creates its own DB (no shared state)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:27:21.751019Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:11:12.432092Z","closed_at":"2026-01-30T18:11:12.432036Z","close_reason":"Integration tests: 10 FTS search tests (stemming, empty, special chars, ordering, triggers, null title), 5 embedding tests (KNN, limit, dedup, orphan trigger, empty DB), 6 hybrid search tests (lexical mode, FTS-only, graceful degradation, RRF ranking, filters, mode variants). 310 total tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2no","depends_on_id":"bd-1x6","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2no","depends_on_id":"bd-3eu","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2no","depends_on_id":"bd-3lu","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2no","depends_on_id":"bd-am7","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2nx","title":"OBSERV Epic: Phase 1 - Verbosity Flags + Structured File Logging","description":"Foundation layer for observability. Add -v/-vv/-vvv CLI flags, dual-layer tracing subscriber (stderr + file), daily log rotation via tracing-appender, log retention cleanup, --log-format json flag, and LoggingConfig.\n\nDepends on: nothing (first phase)\nUnblocks: Phase 2, and transitively all other phases\n\nFiles: Cargo.toml, src/cli/mod.rs, src/main.rs, src/core/config.rs, src/core/paths.rs, src/cli/commands/doctor.rs\n\nAcceptance criteria (PRD Section 6.1):\n- JSON log files written to ~/.local/share/lore/logs/ with zero config\n- -v/-vv/-vvv control stderr verbosity per table in PRD 4.3\n- RUST_LOG overrides -v for both layers\n- --log-format json emits JSON on stderr\n- Daily rotation, retention cleanup on startup\n- --quiet suppresses stderr, does NOT affect file layer\n- lore doctor reports log directory info","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-04T15:53:00.987774Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:15:09.465732Z","closed_at":"2026-02-04T17:15:09.465684Z","close_reason":"Phase 1 complete: dual-layer subscriber, -v/--verbose flags, --log-format json, LoggingConfig, get_log_dir(), log retention, doctor diagnostics","compaction_level":0,"original_size":0,"labels":["observability"]} @@ -252,7 +252,7 @@ {"id":"bd-3sez","title":"Create surgical.rs core module with preflight fetch, ingest functions, and TOCTOU guards","description":"## Background\n\nThe surgical sync pipeline needs a core module (`src/ingestion/surgical.rs`) that fetches a single issue or MR by IID from GitLab and ingests it into the local SQLite database. This replaces the bulk pagination path (`ingest_issues`/`ingest_merge_requests`) for targeted, on-demand sync of specific entities.\n\nKey constraints:\n- `process_single_issue` (issues.rs:143) and `process_single_mr` (merge_requests.rs:144) are private functions. This bead wraps them with pub(crate) entry points that add TOCTOU guard logic and dirty marking.\n- `updated_at` is a `String` (ISO 8601) in `GitLabIssue`/`GitLabMergeRequest` but stored as `INTEGER` (ms-epoch) in the DB. The TOCTOU guard must parse the ISO string to ms-epoch for comparison.\n- `ProcessMrResult` (merge_requests.rs:138) is a private struct. The MR ingest wrapper returns its own result type or re-exports the needed fields.\n- `SyncRunRecorder` has `succeed()` and `fail()` that consume `self`. Not needed here since surgical.rs is called from the orchestrator which owns the recorder.\n\n## Approach\n\nCreate `src/ingestion/surgical.rs` with:\n\n1. **`preflight_fetch`** (async): Takes `&GitLabClient`, `gitlab_project_id`, and a list of `(entity_type, iid)` targets. Calls `client.get_issue_by_iid()` and `client.get_mr_by_iid()` (from bd-159p). Returns `PreflightResult { issues: Vec, merge_requests: Vec, failures: Vec }`.\n\n2. **`ingest_issue_by_iid`** (sync): Takes `&Connection`, `&Config`, `project_id`, `&GitLabIssue`. Applies TOCTOU guard (compare payload `updated_at` parsed to ms-epoch vs DB `updated_at`), then calls `process_single_issue` (requires making it `pub(crate)` in bd-1sc6), marks dirty via `dirty_tracker::mark_dirty(conn, SourceType::Issue, local_issue_id)`, and returns `IngestIssueResult { upserted: bool, labels_created: usize, skipped_stale: bool, dirty_source_keys: Vec<(SourceType, i64)> }`.\n\n3. **`ingest_mr_by_iid`** (sync): Same pattern for MRs. Calls `process_single_mr` (requires `pub(crate)` in bd-1sc6), returns `IngestMrResult { upserted: bool, labels_created: usize, assignees_linked: usize, reviewers_linked: usize, skipped_stale: bool, dirty_source_keys: Vec<(SourceType, i64)> }`.\n\n4. **TOCTOU guard**: `fn is_stale(payload_updated_at: &str, db_updated_at_ms: Option) -> Result`. Parses ISO 8601 string to ms-epoch using `chrono::DateTime::parse_from_rfc3339`. Returns `true` if `payload_ms <= db_ms` (payload is same age or older than what we already have).\n\nWire the module in `src/ingestion/mod.rs`.\n\n## Acceptance Criteria\n\n- [ ] `preflight_fetch` calls GitLabClient by-IID methods and collects successes + failures\n- [ ] `ingest_issue_by_iid` wraps `process_single_issue` with TOCTOU guard and dirty marking\n- [ ] `ingest_mr_by_iid` wraps `process_single_mr` with TOCTOU guard and dirty marking\n- [ ] TOCTOU guard correctly parses ISO 8601 String to ms-epoch for comparison with DB i64\n- [ ] Stale payloads (payload updated_at <= DB updated_at) are skipped, not ingested\n- [ ] `dirty_source_keys` returned include the `(SourceType, source_id)` tuples for downstream scoped doc regen\n- [ ] Module registered in `src/ingestion/mod.rs`\n- [ ] All tests from bd-x8oq pass\n\n## Files\n\n- `src/ingestion/surgical.rs` (NEW)\n- `src/ingestion/mod.rs` (add `pub(crate) mod surgical;`)\n- `src/ingestion/issues.rs` (change `process_single_issue` to `pub(crate)` — done in bd-1sc6)\n- `src/ingestion/merge_requests.rs` (change `process_single_mr` and `ProcessMrResult` to `pub(crate)` — done in bd-1sc6)\n\n## TDD Anchor\n\nTests live in bd-x8oq (`src/ingestion/surgical_tests.rs`), referenced via `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;` in surgical.rs. Key tests that validate this bead:\n\n- `test_ingest_issue_by_iid_upserts_and_marks_dirty` — verifies full issue ingest path + dirty marking\n- `test_ingest_mr_by_iid_upserts_and_marks_dirty` — verifies full MR ingest path + dirty marking\n- `test_toctou_skips_stale_issue` — inserts issue with updated_at=T1, calls ingest with payload updated_at=T1, asserts skipped_stale=true\n- `test_toctou_skips_stale_mr` — same for MRs\n- `test_toctou_allows_newer_issue` — payload T2 > DB T1, asserts upserted=true\n- `test_is_stale_parses_iso8601` — unit test for the ISO 8601 to ms-epoch parsing\n- `test_is_stale_handles_none_db_value` — first ingest (no existing row), should return false (not stale)\n- `test_preflight_fetch_returns_issues_and_mrs` — wiremock test for successful preflight\n- `test_preflight_fetch_collects_failures` — wiremock 404 returns failure, not error\n\n## Edge Cases\n\n- ISO 8601 with timezone offset (GitLab returns `+00:00` not `Z`) must parse correctly\n- First-ever ingest of an IID: no existing DB row, TOCTOU guard must treat as \"not stale\" (db_updated_at is None)\n- GitLab returns 404 for a deleted issue/MR during preflight: failure, not hard error\n- Concurrent surgical syncs for same IID: `process_single_issue` uses `unchecked_transaction()` with UPSERT, so last-writer-wins is safe\n- `process_single_mr` returns `ProcessMrResult` which is private: either make it `pub(crate)` in bd-1sc6 or replicate needed fields\n\n## Dependency Context\n\n- **Blocked by bd-159p**: `get_issue_by_iid` and `get_mr_by_iid` on GitLabClient (preflight needs these)\n- **Blocked by bd-1sc6**: Visibility changes to `process_single_issue`, `process_single_mr`, `ProcessMrResult` (must be `pub(crate)`)\n- **Blocks bd-1i4i**: Orchestration function calls `preflight_fetch` + `ingest_issue_by_iid` / `ingest_mr_by_iid`\n- **Blocks bd-kanh**: Dependent helpers are called after ingest to fetch discussions, resource events, etc.\n- **Blocks bd-wcja**: SyncResult surgical fields depend on return types from this module\n- **Co-depends with bd-x8oq**: Tests for this code live in that bead's test file","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-17T19:14:19.449695Z","created_by":"tayloreernisse","updated_at":"2026-02-17T20:02:01.692160Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-3sez","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-3jqx","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-kanh","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-wcja","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-x8oq","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} {"id":"bd-3sh","title":"Add 'lore count events' command with robot mode","description":"## Background\nNeed to verify event ingestion and report counts by type. The existing count command (src/cli/commands/count.rs) handles issues, mrs, discussions, notes with both human and robot output. This adds 'events' as a new count subcommand.\n\n## Approach\nExtend the existing count command in src/cli/commands/count.rs:\n\n1. Add CountTarget::Events variant (or string match) in the count dispatcher\n2. Query each event table with GROUP BY entity type:\n```sql\nSELECT \n CASE WHEN issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END as entity_type,\n COUNT(*) as count\nFROM resource_state_events\nGROUP BY entity_type;\n-- (repeat for label and milestone events)\n```\n\n3. Human output: table format\n```\nEvent Type Issues MRs Total\nState events 1,234 567 1,801\nLabel events 2,345 890 3,235\nMilestone events 456 123 579\nTotal 4,035 1,580 5,615\n```\n\n4. Robot JSON:\n```json\n{\n \"ok\": true,\n \"data\": {\n \"state_events\": {\"issue\": 1234, \"merge_request\": 567, \"total\": 1801},\n \"label_events\": {\"issue\": 2345, \"merge_request\": 890, \"total\": 3235},\n \"milestone_events\": {\"issue\": 456, \"merge_request\": 123, \"total\": 579},\n \"total\": 5615\n }\n}\n```\n\n5. Register in CLI: add \"events\" to count's entity_type argument in src/cli/mod.rs\n\n## Acceptance Criteria\n- [ ] `lore count events` shows correct counts by event type and entity type\n- [ ] Robot JSON matches the schema above\n- [ ] Works with empty tables (all zeros)\n- [ ] Does not error if migration 011 hasn't been applied (graceful degradation or \"no event tables\" message)\n\n## Files\n- src/cli/commands/count.rs (add events counting logic)\n- src/cli/mod.rs (add \"events\" to count's accepted entity types)\n\n## TDD Loop\nRED: tests/count_tests.rs (or extend existing):\n- `test_count_events_empty_tables` - verify all zeros on fresh DB\n- `test_count_events_with_data` - seed state + label events, verify correct counts\n- `test_count_events_robot_json` - verify JSON structure\n\nGREEN: Add the events branch to count command\n\nVERIFY: `cargo test count -- --nocapture`\n\n## Edge Cases\n- Tables don't exist if user hasn't run migrate — check table existence first or catch the error\n- COUNT with GROUP BY returns no rows for empty tables — need to handle missing entity types as 0","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T21:31:57.379702Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:21:21.408874Z","closed_at":"2026-02-03T16:21:21.408806Z","close_reason":"Added 'events' to count CLI parser, run_count_events function, print_event_count (table format) and print_event_count_json (structured JSON). Wired into handle_count in main.rs.","compaction_level":0,"original_size":0,"labels":["cli","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-3sh","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3sh","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-3t1b","title":"Implement MR Detail (state + action + view)","description":"## Background\nThe MR Detail shows a single merge request with file changes, diff discussions (position-specific comments), and general discussions. Same progressive hydration pattern as Issue Detail. MR detail has additional sections: file change list and diff-context notes.\n\n## Approach\nState (state/mr_detail.rs):\n- MrDetailState: current_key (Option), metadata (Option), discussions (Vec), diff_discussions (Vec), file_changes (Vec), cross_refs (Vec), tree_state (TreePersistState), scroll_offset, active_tab (MrTab: Overview|Files|Discussions)\n- MrMetadata: iid, title, description, state, author, reviewer, assignee, labels, target_branch, source_branch, created_at, updated_at, web_url, draft, merge_status\n- FileChange: old_path, new_path, change_type (added/modified/deleted/renamed), diff_line_count\n- DiffDiscussion: file_path, old_line, new_line, notes (Vec)\n\nAction (action.rs):\n- fetch_mr_detail(conn, key, clock) -> Result: uses with_read_snapshot\n\nView (view/mr_detail.rs):\n- render_mr_detail(frame, state, area, theme): header, tab bar (Overview|Files|Discussions), tab content\n- Overview tab: description + cross-refs\n- Files tab: file change list with change type indicators (+/-/~)\n- Discussions tab: general discussions + diff discussions grouped by file\n\n## Acceptance Criteria\n- [ ] MR metadata loads in Phase 1\n- [ ] Tab navigation between Overview, Files, Discussions\n- [ ] File changes list shows change type and line count\n- [ ] Diff discussions grouped by file path\n- [ ] General discussions rendered in tree widget\n- [ ] Cross-references navigable (related issues, etc.)\n- [ ] All text sanitized via sanitize_for_terminal()\n- [ ] Esc returns to MR List with state preserved\n\n## Files\n- MODIFY: crates/lore-tui/src/state/mr_detail.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_mr_detail)\n- CREATE: crates/lore-tui/src/view/mr_detail.rs\n\n## TDD Anchor\nRED: Write test_fetch_mr_detail in action.rs that inserts an MR with 3 file changes, calls fetch_mr_detail, asserts 3 files returned.\nGREEN: Implement fetch_mr_detail with file change query.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_mr_detail\n\n## Edge Cases\n- MR with no file changes (draft MR created without pushes): show \"No file changes\" message\n- Diff discussions referencing deleted files: show file path with strikethrough style\n- Very large MRs (hundreds of files): paginate file list, don't load all at once\n\n## Dependency Context\nUses discussion tree and cross-ref widgets from \"Implement discussion tree + cross-reference widgets\" task.\nUses same patterns as \"Implement Issue Detail\" task.\nUses MrDetailState from \"Implement AppState composition\" task.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:38.427124Z","created_by":"tayloreernisse","updated_at":"2026-02-18T20:36:38.457188Z","closed_at":"2026-02-18T20:36:38.457090Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3t1b","depends_on_id":"bd-1d6z","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3t1b","depends_on_id":"bd-2kr0","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} -{"id":"bd-3t6r","title":"Epic: TUI Phase 5 — Polish","description":"## Background\nPhase 5 adds polish features: responsive breakpoints for all screens, session state persistence (resume where you left off), single-instance locking, entity/render caches for performance, text width handling for Unicode, snapshot tests, and terminal compatibility test matrix.\n\n## Acceptance Criteria\n- [ ] All screens adapt to terminal width with responsive breakpoints\n- [ ] Session state persisted and restored on relaunch\n- [ ] Single-instance lock prevents concurrent TUI launches\n- [ ] Entity cache enables near-instant detail view reopens\n- [ ] Snapshot tests produce deterministic output with FakeClock\n- [ ] Terminal compat verified across iTerm2, tmux, Alacritty, kitty","status":"in_progress","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:02:47.178645Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:01:12.262228Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3t6r","depends_on_id":"bd-1df9","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} +{"id":"bd-3t6r","title":"Epic: TUI Phase 5 — Polish","description":"## Background\nPhase 5 adds polish features: responsive breakpoints for all screens, session state persistence (resume where you left off), single-instance locking, entity/render caches for performance, text width handling for Unicode, snapshot tests, and terminal compatibility test matrix.\n\n## Acceptance Criteria\n- [ ] All screens adapt to terminal width with responsive breakpoints\n- [ ] Session state persisted and restored on relaunch\n- [ ] Single-instance lock prevents concurrent TUI launches\n- [ ] Entity cache enables near-instant detail view reopens\n- [ ] Snapshot tests produce deterministic output with FakeClock\n- [ ] Terminal compat verified across iTerm2, tmux, Alacritty, kitty","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:02:47.178645Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:26:32.195896Z","closed_at":"2026-02-19T05:26:32.195767Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3t6r","depends_on_id":"bd-1df9","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-3ty8","title":"Implement Bootstrap screen + schema preflight","description":"## Background\nThe Bootstrap screen handles first-launch and incompatible-database scenarios. Before entering the TUI event loop, a schema preflight check validates the database is compatible. If not, an actionable error is shown. The Bootstrap screen also guides users through initial sync if the database is empty.\n\n## Approach\n- Schema preflight in lib.rs: check schema version before creating LoreApp. If incompatible, print error with lore migrate suggestion and exit non-zero.\n- Bootstrap screen (Screen::Bootstrap): shown when database has zero issues/MRs. Shows: \"No data found. Run sync to get started.\" with option to start sync inline.\n- State: BootstrapState { has_data: bool, schema_ok: bool, config_valid: bool }\n- Action: check_data_readiness(conn) -> DataReadiness { has_issues: bool, has_mrs: bool, has_documents: bool, schema_version: i32 }\n\n## Acceptance Criteria\n- [ ] Schema preflight yields actionable error for incompatible DB versions\n- [ ] Bootstrap screen shown when database is empty\n- [ ] Bootstrap guides user to start sync\n- [ ] After sync completes, Bootstrap auto-transitions to Dashboard\n- [ ] Non-zero exit code on schema incompatibility\n\n## Files\n- CREATE: crates/lore-tui/src/state/bootstrap.rs\n- CREATE: crates/lore-tui/src/view/bootstrap.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add schema preflight check)\n- MODIFY: crates/lore-tui/src/action.rs (add check_data_readiness)\n\n## TDD Anchor\nRED: Write test_schema_preflight_rejects_old that creates DB at schema version 1, asserts preflight returns error.\nGREEN: Implement schema version check.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_schema_preflight\n\n## Edge Cases\n- Database file doesn't exist: create it, then show Bootstrap\n- Database locked by another process: show DbBusy error with suggestion\n- Config file missing: show error with lore init suggestion","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:02.185699Z","created_by":"tayloreernisse","updated_at":"2026-02-18T20:58:27.145495Z","closed_at":"2026-02-18T20:58:27.145430Z","close_reason":"Implemented Bootstrap screen + schema preflight: state/bootstrap.rs (DataReadiness, SchemaCheck, BootstrapState), action.rs (check_schema_version, check_data_readiness, MINIMUM_SCHEMA_VERSION), view/bootstrap.rs (render_bootstrap), lib.rs (schema_preflight), update.rs (SyncStarted/SyncCompleted bootstrap auto-transition). 405 tests passing, clippy clean.","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3ty8","depends_on_id":"bd-1cl9","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3ty8","depends_on_id":"bd-6pmy","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-3vqk","title":"OBSERV: Add rate_limit_hits and retries counters to StageTiming","description":"## Background\nMetricsLayer counts span timing but doesn't yet count rate-limit hits and retries. These counters complete the observability picture, showing HOW MUCH time was spent waiting vs. working.\n\n## Approach\n### src/core/metrics.rs - StageTiming struct\n\nAdd two new fields:\n```rust\n#[derive(Debug, Clone, Serialize)]\npub struct StageTiming {\n // ... existing fields ...\n #[serde(skip_serializing_if = \"is_zero\")]\n pub rate_limit_hits: usize,\n #[serde(skip_serializing_if = \"is_zero\")]\n pub retries: usize,\n}\n```\n\n### src/core/metrics.rs - MetricsLayer\n\nThe structured log events from bd-12ae use info!() with specific fields (status_code=429, \"Rate limited, retrying\"). MetricsLayer needs to count these events within each span.\n\nAdd to SpanData:\n```rust\nstruct SpanData {\n // ... existing fields ...\n rate_limit_hits: usize,\n retries: usize,\n}\n```\n\nAdd on_event() to MetricsLayer:\n```rust\nfn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) {\n // Check if event message contains rate-limit or retry indicators\n // Increment counters on the current span\n if let Some(span_ref) = ctx.event_span(event) {\n let id = span_ref.id();\n if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {\n let mut visitor = EventVisitor::default();\n event.record(&mut visitor);\n\n if visitor.status_code == Some(429) {\n data.rate_limit_hits += 1;\n }\n if visitor.is_retry {\n data.retries += 1;\n }\n }\n }\n}\n```\n\nThe EventVisitor checks for status_code=429 and message containing \"retrying\" to classify events.\n\nOn span close, propagate counts to parent (bubble up):\n```rust\nfn on_close(&self, id: Id, _ctx: Context<'_, S>) {\n if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {\n let timing = StageTiming {\n // ... existing fields ...\n rate_limit_hits: data.rate_limit_hits,\n retries: data.retries,\n };\n // ... push to completed\n }\n}\n```\n\n## Acceptance Criteria\n- [ ] StageTiming has rate_limit_hits and retries fields\n- [ ] Fields omitted when zero in JSON serialization\n- [ ] MetricsLayer counts 429 events as rate_limit_hits\n- [ ] MetricsLayer counts retry events as retries\n- [ ] Counts bubble up to parent spans in extract_timings()\n- [ ] Rate limit counts appear in metrics_json stored in sync_runs\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/metrics.rs (add fields to StageTiming, add on_event to MetricsLayer, add EventVisitor)\n\n## TDD Loop\nRED:\n - test_stage_timing_rate_limit_counts: simulate 3 rate-limit events, extract, assert rate_limit_hits=3\n - test_stage_timing_retry_counts: simulate 2 retries, extract, assert retries=2\n - test_rate_limit_fields_omitted_when_zero: StageTiming with zero counts, serialize, assert no keys\nGREEN: Add fields to StageTiming, implement on_event in MetricsLayer\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Events outside any span: ctx.event_span() returns None. Skip counting. This shouldn't happen in practice since all GitLab calls happen within stage spans.\n- Event classification: rely on structured fields (status_code=429) not message text. More reliable and less fragile.\n- Count bubbling: parent stage should aggregate child counts. In extract_timings(), sum children's counts into parent.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:55:02.523778Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:25:25.456758Z","closed_at":"2026-02-04T17:25:25.456708Z","close_reason":"Implemented rate_limit_hits and retries counters in StageTiming with skip_serializing_if for zero values","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-3vqk","depends_on_id":"bd-12ae","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3vqk","depends_on_id":"bd-1o4h","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3vqk","depends_on_id":"bd-3pk","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-4qd","title":"Write unit tests for core algorithms","description":"## Background\nUnit tests verify the core algorithms in isolation: document extraction formatting, FTS query sanitization, RRF scoring, content hashing, backoff curves, and filter helpers. These tests don't require a database or external services — they test pure functions and logic.\n\n## Approach\nAdd #[cfg(test)] mod tests blocks to each module:\n\n**1. src/documents/extractor.rs:**\n- test_source_type_parse_all_aliases — every alias resolves correctly\n- test_source_type_parse_unknown — returns None\n- test_source_type_as_str_roundtrip — as_str matches parse input\n- test_content_hash_deterministic — same input = same hash\n- test_list_hash_order_independent — sorted before hashing\n- test_list_hash_empty — empty vec produces consistent hash\n\n**2. src/documents/truncation.rs:**\n- test_truncation_edge_cases (per bd-18t TDD Loop)\n\n**3. src/search/fts.rs:**\n- test_to_fts_query_basic — \"auth error\" -> quoted tokens\n- test_to_fts_query_prefix — \"auth*\" preserves prefix\n- test_to_fts_query_special_chars — \"C++\" quoted correctly\n- test_to_fts_query_dash — \"-DWITH_SSL\" quoted (not NOT operator)\n- test_to_fts_query_internal_quotes — escaped by doubling\n- test_to_fts_query_empty — empty string returns empty\n\n**4. src/search/rrf.rs:**\n- test_rrf_dual_list — docs in both lists score higher\n- test_rrf_normalization — best score = 1.0\n- test_rrf_empty — empty returns empty\n\n**5. src/core/backoff.rs:**\n- test_exponential_curve — delays double each attempt\n- test_cap_at_one_hour — high attempt_count capped\n- test_jitter_range — within [0.9, 1.1) factor\n\n**6. src/search/filters.rs:**\n- test_has_any_filter — true/false for various filter combos\n- test_clamp_limit — 0->20, 200->100, 50->50\n- test_path_filter_from_str — trailing slash = Prefix\n\n**7. src/search/hybrid.rs (hydration round-trip):**\n- test_single_round_trip_query — verify hydration SQL produces correct structure\n\n## Acceptance Criteria\n- [ ] All edge cases covered per PRD acceptance criteria\n- [ ] Tests are unit tests (no DB, no network, no Ollama)\n- [ ] `cargo test` passes with all new tests\n- [ ] No test depends on execution order\n- [ ] Tests cover: document extractor formats, truncation, RRF, hashing, FTS sanitization, backoff, filters\n\n## Files\n- In-module tests in: extractor.rs, truncation.rs, fts.rs, rrf.rs, backoff.rs, filters.rs, hybrid.rs\n\n## TDD Loop\nThese tests ARE the TDD loop for their respective beads. Each implementation bead should write its tests first (RED), then implement (GREEN).\nVERIFY: `cargo test`\n\n## Edge Cases\n- Tests with Unicode: include emoji, CJK characters in truncation tests\n- Tests with empty strings: empty queries, empty content, empty labels\n- Tests with boundary values: limit=0, limit=100, limit=101","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:27:21.712924Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:46:00.059346Z","closed_at":"2026-01-30T17:46:00.059292Z","close_reason":"All acceptance criteria tests already exist across modules. 276 tests passing (189 unit + 87 integration).","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-4qd","depends_on_id":"bd-18t","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-1k1","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-3ez","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-mem","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} diff --git a/.beads/last-touched b/.beads/last-touched index 61f4cdf..28f2c86 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -bd-3rjw +bd-2nfs diff --git a/crates/lore-tui/TERMINAL_COMPAT.md b/crates/lore-tui/TERMINAL_COMPAT.md new file mode 100644 index 0000000..6100acf --- /dev/null +++ b/crates/lore-tui/TERMINAL_COMPAT.md @@ -0,0 +1,61 @@ +# Terminal Compatibility Matrix + +Manual verification checklist for lore-tui rendering across terminal emulators. + +**How to use:** Run `cargo run -p lore-tui` in each terminal, navigate through +all screens, and mark each cell with one of: +- OK — works correctly +- PARTIAL — works with minor visual glitches (describe in Notes) +- FAIL — broken or unusable (describe in Notes) +- N/T — not tested + +Last verified: _not yet_ + +## Rendering Features + +| Feature | iTerm2 | tmux | Alacritty | kitty | WezTerm | +|----------------------|--------|------|-----------|-------|---------| +| True color (RGB) | | | | | | +| Unicode box-drawing | | | | | | +| CJK wide characters | | | | | | +| Bold text | | | | | | +| Italic text | | | | | | +| Underline | | | | | | +| Dim / faint | | | | | | +| Strikethrough | | | | | | + +## Interaction Features + +| Feature | iTerm2 | tmux | Alacritty | kitty | WezTerm | +|----------------------|--------|------|-----------|-------|---------| +| Keyboard input | | | | | | +| Mouse click | | | | | | +| Mouse scroll | | | | | | +| Resize handling | | | | | | +| Alt screen toggle | | | | | | +| Bracketed paste | | | | | | + +## Screen-Specific Checks + +| Screen | iTerm2 | tmux | Alacritty | kitty | WezTerm | +|----------------------|--------|------|-----------|-------|---------| +| Dashboard | | | | | | +| Issue list | | | | | | +| Issue detail | | | | | | +| MR list | | | | | | +| MR detail | | | | | | +| Search | | | | | | +| Command palette | | | | | | +| Help overlay | | | | | | + +## Minimum Sizes + +| Terminal size | Renders correctly? | Notes | +|---------------|-------------------|-------| +| 80x24 | | | +| 120x40 | | | +| 200x60 | | | + +## Notes + +_Record any issues, workarounds, or version-specific quirks here._ diff --git a/crates/lore-tui/tests/snapshot_tests.rs b/crates/lore-tui/tests/snapshot_tests.rs new file mode 100644 index 0000000..862cd1c --- /dev/null +++ b/crates/lore-tui/tests/snapshot_tests.rs @@ -0,0 +1,453 @@ +//! Snapshot tests for deterministic TUI rendering. +//! +//! Each test renders a screen at a fixed terminal size (120x40) with +//! FakeClock frozen at 2026-01-15T12:00:00Z, then compares the plain-text +//! output against a golden file in `tests/snapshots/`. +//! +//! To update golden files after intentional changes: +//! UPDATE_SNAPSHOTS=1 cargo test -p lore-tui snapshot +//! +//! Golden files are UTF-8 plain text with LF line endings, diffable in VCS. + +use std::path::PathBuf; + +use chrono::{TimeZone, Utc}; +use ftui::Model; +use ftui::render::frame::Frame; +use ftui::render::grapheme_pool::GraphemePool; + +use lore_tui::app::LoreApp; +use lore_tui::clock::FakeClock; +use lore_tui::message::{EntityKey, Msg, Screen, SearchResult}; +use lore_tui::state::dashboard::{DashboardData, EntityCounts, LastSyncInfo, ProjectSyncInfo}; +use lore_tui::state::issue_detail::{IssueDetailData, IssueMetadata}; +use lore_tui::state::issue_list::{IssueListPage, IssueListRow}; +use lore_tui::state::mr_list::{MrListPage, MrListRow}; +use lore_tui::task_supervisor::TaskKey; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Fixed terminal size for all snapshot tests. +const WIDTH: u16 = 120; +const HEIGHT: u16 = 40; + +/// Frozen clock epoch: 2026-01-15T12:00:00Z. +fn frozen_clock() -> FakeClock { + FakeClock::new(Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap()) +} + +/// Path to the snapshots directory (relative to crate root). +fn snapshots_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots") +} + +// --------------------------------------------------------------------------- +// Buffer serializer +// --------------------------------------------------------------------------- + +/// Serialize a Frame's buffer to plain text. +/// +/// - Direct chars are rendered as-is. +/// - Grapheme references are resolved via the pool. +/// - Continuation cells (wide char trailing cells) are skipped. +/// - Empty cells become spaces. +/// - Each row is right-trimmed and joined with '\n'. +fn serialize_frame(frame: &Frame<'_>) -> String { + let w = frame.buffer.width(); + let h = frame.buffer.height(); + let mut lines = Vec::with_capacity(h as usize); + + for y in 0..h { + let mut row = String::with_capacity(w as usize); + for x in 0..w { + if let Some(cell) = frame.buffer.get(x, y) { + let content = cell.content; + if content.is_continuation() { + // Skip — part of a wide character already rendered. + continue; + } else if content.is_empty() { + row.push(' '); + } else if let Some(ch) = content.as_char() { + row.push(ch); + } else if let Some(gid) = content.grapheme_id() { + if let Some(grapheme) = frame.pool.get(gid) { + row.push_str(grapheme); + } else { + row.push('?'); // Fallback for unresolved grapheme. + } + } else { + row.push(' '); + } + } else { + row.push(' '); + } + } + lines.push(row.trim_end().to_string()); + } + + // Trim trailing empty lines. + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + + let mut result = lines.join("\n"); + result.push('\n'); // Trailing newline for VCS friendliness. + result +} + +// --------------------------------------------------------------------------- +// Snapshot assertion +// --------------------------------------------------------------------------- + +/// Compare rendered output against a golden file. +/// +/// If `UPDATE_SNAPSHOTS=1` is set, overwrites the golden file instead. +/// On mismatch, prints a clear diff showing expected vs actual. +fn assert_snapshot(name: &str, actual: &str) { + let path = snapshots_dir().join(format!("{name}.snap")); + + if std::env::var("UPDATE_SNAPSHOTS").is_ok() { + std::fs::write(&path, actual).unwrap_or_else(|e| { + panic!("Failed to write snapshot {}: {e}", path.display()); + }); + eprintln!("Updated snapshot: {}", path.display()); + return; + } + + if !path.exists() { + panic!( + "Golden file missing: {}\n\ + Run with UPDATE_SNAPSHOTS=1 to create it.\n\ + Actual output:\n{}", + path.display(), + actual + ); + } + + let expected = std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!("Failed to read snapshot {}: {e}", path.display()); + }); + + if actual != expected { + // Print a useful diff. + let actual_lines: Vec<&str> = actual.lines().collect(); + let expected_lines: Vec<&str> = expected.lines().collect(); + let max = actual_lines.len().max(expected_lines.len()); + + let mut diff = String::new(); + for i in 0..max { + let a = actual_lines.get(i).copied().unwrap_or(""); + let e = expected_lines.get(i).copied().unwrap_or(""); + if a != e { + diff.push_str(&format!(" line {i:3}: expected: {e:?}\n")); + diff.push_str(&format!(" line {i:3}: actual: {a:?}\n")); + } + } + + panic!( + "Snapshot mismatch: {}\n\ + Run with UPDATE_SNAPSHOTS=1 to update.\n\n\ + Differences:\n{diff}", + path.display() + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn test_app() -> LoreApp { + let mut app = LoreApp::new(); + app.clock = Box::new(frozen_clock()); + app +} + +fn render_app(app: &LoreApp) -> String { + let mut pool = GraphemePool::new(); + let mut frame = Frame::new(WIDTH, HEIGHT, &mut pool); + app.view(&mut frame); + serialize_frame(&frame) +} + +// -- Synthetic data fixtures ------------------------------------------------ + +fn fixture_dashboard_data() -> DashboardData { + DashboardData { + counts: EntityCounts { + issues_total: 42, + issues_open: 15, + mrs_total: 28, + mrs_open: 7, + discussions: 120, + notes_total: 350, + notes_system_pct: 18, + documents: 85, + embeddings: 200, + }, + projects: vec![ + ProjectSyncInfo { + path: "infra/platform".into(), + minutes_since_sync: 5, + }, + ProjectSyncInfo { + path: "web/frontend".into(), + minutes_since_sync: 12, + }, + ProjectSyncInfo { + path: "api/backend".into(), + minutes_since_sync: 8, + }, + ProjectSyncInfo { + path: "tools/scripts".into(), + minutes_since_sync: 4, + }, + ], + recent: vec![], + last_sync: Some(LastSyncInfo { + status: "succeeded".into(), + // 2026-01-15T11:55:00Z — 5 min before frozen clock. + finished_at: Some(1_736_942_100_000), + command: "sync".into(), + error: None, + }), + } +} + +fn fixture_issue_list() -> IssueListPage { + IssueListPage { + rows: vec![ + IssueListRow { + project_path: "infra/platform".into(), + iid: 101, + title: "Add retry logic for transient failures".into(), + state: "opened".into(), + author: "alice".into(), + labels: vec!["backend".into(), "reliability".into()], + updated_at: 1_736_942_000_000, // ~5 min before frozen + }, + IssueListRow { + project_path: "web/frontend".into(), + iid: 55, + title: "Dark mode toggle not persisting across sessions".into(), + state: "opened".into(), + author: "bob".into(), + labels: vec!["ui".into(), "bug".into()], + updated_at: 1_736_938_400_000, // ~1 hr before frozen + }, + IssueListRow { + project_path: "api/backend".into(), + iid: 203, + title: "Migrate user service to async runtime".into(), + state: "closed".into(), + author: "carol".into(), + labels: vec!["backend".into(), "refactor".into()], + updated_at: 1_736_856_000_000, // ~1 day before frozen + }, + ], + next_cursor: None, + total_count: 3, + } +} + +fn fixture_issue_detail() -> IssueDetailData { + IssueDetailData { + metadata: IssueMetadata { + iid: 101, + project_path: "infra/platform".into(), + title: "Add retry logic for transient failures".into(), + description: "## Problem\n\nTransient network failures cause cascading \ + errors in the ingestion pipeline. We need exponential \ + backoff with jitter.\n\n## Approach\n\n1. Wrap HTTP calls \ + in a retry decorator\n2. Use exponential backoff (base 1s, \ + max 30s)\n3. Add jitter to prevent thundering herd" + .into(), + state: "opened".into(), + author: "alice".into(), + assignees: vec!["bob".into(), "carol".into()], + labels: vec!["backend".into(), "reliability".into()], + milestone: Some("v2.0".into()), + due_date: Some("2026-02-01".into()), + created_at: 1_736_856_000_000, // ~1 day before frozen + updated_at: 1_736_942_000_000, + web_url: "https://gitlab.com/infra/platform/-/issues/101".into(), + discussion_count: 3, + }, + cross_refs: vec![], + } +} + +fn fixture_mr_list() -> MrListPage { + MrListPage { + rows: vec![ + MrListRow { + project_path: "infra/platform".into(), + iid: 42, + title: "Implement exponential backoff for HTTP client".into(), + state: "opened".into(), + author: "bob".into(), + labels: vec!["backend".into()], + updated_at: 1_736_942_000_000, + draft: false, + target_branch: "main".into(), + }, + MrListRow { + project_path: "web/frontend".into(), + iid: 88, + title: "WIP: Redesign settings page".into(), + state: "opened".into(), + author: "alice".into(), + labels: vec!["ui".into()], + updated_at: 1_736_938_400_000, + draft: true, + target_branch: "main".into(), + }, + ], + next_cursor: None, + total_count: 2, + } +} + +fn fixture_search_results() -> Vec { + vec![ + SearchResult { + key: EntityKey::issue(1, 101), + title: "Add retry logic for transient failures".into(), + snippet: "...exponential backoff with jitter for transient network...".into(), + score: 0.95, + project_path: "infra/platform".into(), + }, + SearchResult { + key: EntityKey::mr(1, 42), + title: "Implement exponential backoff for HTTP client".into(), + snippet: "...wraps reqwest calls in retry decorator with backoff...".into(), + score: 0.82, + project_path: "infra/platform".into(), + }, + ] +} + +// -- Data injection helpers ------------------------------------------------- + +fn load_dashboard(app: &mut LoreApp) { + let generation = app + .supervisor + .submit(TaskKey::LoadScreen(Screen::Dashboard)) + .generation; + app.update(Msg::DashboardLoaded { + generation, + data: Box::new(fixture_dashboard_data()), + }); +} + +fn load_issue_list(app: &mut LoreApp) { + app.update(Msg::NavigateTo(Screen::IssueList)); + let generation = app + .supervisor + .submit(TaskKey::LoadScreen(Screen::IssueList)) + .generation; + app.update(Msg::IssueListLoaded { + generation, + page: fixture_issue_list(), + }); +} + +fn load_issue_detail(app: &mut LoreApp) { + let key = EntityKey::issue(1, 101); + let screen = Screen::IssueDetail(key.clone()); + app.update(Msg::NavigateTo(screen.clone())); + let generation = app + .supervisor + .submit(TaskKey::LoadScreen(screen)) + .generation; + app.update(Msg::IssueDetailLoaded { + generation, + key, + data: Box::new(fixture_issue_detail()), + }); +} + +fn load_mr_list(app: &mut LoreApp) { + app.update(Msg::NavigateTo(Screen::MrList)); + let generation = app + .supervisor + .submit(TaskKey::LoadScreen(Screen::MrList)) + .generation; + app.update(Msg::MrListLoaded { + generation, + page: fixture_mr_list(), + }); +} + +fn load_search_results(app: &mut LoreApp) { + app.update(Msg::NavigateTo(Screen::Search)); + // Set the query text first so the search state has context. + app.update(Msg::SearchQueryChanged("retry backoff".into())); + let generation = app + .supervisor + .submit(TaskKey::LoadScreen(Screen::Search)) + .generation; + app.update(Msg::SearchExecuted { + generation, + results: fixture_search_results(), + }); +} + +// --------------------------------------------------------------------------- +// Snapshot tests +// --------------------------------------------------------------------------- + +#[test] +fn test_dashboard_snapshot() { + let mut app = test_app(); + load_dashboard(&mut app); + let output = render_app(&app); + assert_snapshot("dashboard_default", &output); +} + +#[test] +fn test_issue_list_snapshot() { + let mut app = test_app(); + load_dashboard(&mut app); // Load dashboard first for realistic nav. + load_issue_list(&mut app); + let output = render_app(&app); + assert_snapshot("issue_list_default", &output); +} + +#[test] +fn test_issue_detail_snapshot() { + let mut app = test_app(); + load_dashboard(&mut app); + load_issue_list(&mut app); + load_issue_detail(&mut app); + let output = render_app(&app); + assert_snapshot("issue_detail", &output); +} + +#[test] +fn test_mr_list_snapshot() { + let mut app = test_app(); + load_dashboard(&mut app); + load_mr_list(&mut app); + let output = render_app(&app); + assert_snapshot("mr_list_default", &output); +} + +#[test] +fn test_search_results_snapshot() { + let mut app = test_app(); + load_dashboard(&mut app); + load_search_results(&mut app); + let output = render_app(&app); + assert_snapshot("search_results", &output); +} + +#[test] +fn test_empty_state_snapshot() { + let app = test_app(); + // No data loaded — Dashboard with initial/empty state. + let output = render_app(&app); + assert_snapshot("empty_state", &output); +} diff --git a/crates/lore-tui/tests/snapshots/dashboard_default.snap b/crates/lore-tui/tests/snapshots/dashboard_default.snap new file mode 100644 index 0000000..0be1da2 --- /dev/null +++ b/crates/lore-tui/tests/snapshots/dashboard_default.snap @@ -0,0 +1,40 @@ +Dashboard + Entity Counts Projects Recent Activity +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Issues: 15 open / 42 ● 5m ago infra/platform No recent activity + MRs: 7 open / 28 ● 12m ago web/frontend + Discussions: 120 ● 8m ago api/backend + Notes: 350 (18% system) ● 4m ago tools/scripts + Documents: 85 + Embeddings: 200 Last sync: succeeded + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NORMAL q:quit esc:back ?:help C-p:palette o:browser P:scope gh:home gi:issues gm:mrs g/:search gt:timeline diff --git a/crates/lore-tui/tests/snapshots/empty_state.snap b/crates/lore-tui/tests/snapshots/empty_state.snap new file mode 100644 index 0000000..fadce0d --- /dev/null +++ b/crates/lore-tui/tests/snapshots/empty_state.snap @@ -0,0 +1,40 @@ +Dashboard + Entity Counts Projects Recent Activity +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Issues: 0 open / 0 No projects synced No recent activity + MRs: 0 open / 0 + Discussions: 0 + Notes: 0 (0% system) + Documents: 0 + Embeddings: 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NORMAL q:quit esc:back ?:help C-p:palette o:browser P:scope gh:home gi:issues gm:mrs g/:search gt:timeline diff --git a/crates/lore-tui/tests/snapshots/issue_detail.snap b/crates/lore-tui/tests/snapshots/issue_detail.snap new file mode 100644 index 0000000..e26a516 --- /dev/null +++ b/crates/lore-tui/tests/snapshots/issue_detail.snap @@ -0,0 +1,40 @@ +Dashboard > Issues > Issue +#101 Add retry logic for transient failures +opened | alice | backend, reliability | -> bob, carol +Milestone: v2.0 | Due: 2026-02-01 +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +## Problem + +Transient network failures cause cascading errors in the ingestion pipeline. We need exponential backoff with jitter. + +## Approach + +1. Wrap HTTP calls in a retry decorator +2. Use exponential backoff (base 1s, max 30s) +3. Add jitter to prevent thundering herd +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +Discussions (0) + Loading discussions... + + + + + + + + + + + + + + + + + + + + + + + NORMAL q:quit esc:back ?:help C-p:palette o:browser P:scope gh:home gi:issues gm:mrs g/:search gt:timeline diff --git a/crates/lore-tui/tests/snapshots/issue_list_default.snap b/crates/lore-tui/tests/snapshots/issue_list_default.snap new file mode 100644 index 0000000..10939e2 --- /dev/null +++ b/crates/lore-tui/tests/snapshots/issue_list_default.snap @@ -0,0 +1,40 @@ +Dashboard > Issues +/ type / to filter +IID v Title State Author Labels Project +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + #101 Add retry logic for transient failures opened alice backend, reliability infra/platform + #55 Dark mode toggle not persisting across sessi opened bob ui, bug web/frontend + #203 Migrate user service to async runtime closed carol backend, refactor api/backend + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Showing 3 of 3 issues + NORMAL q:quit esc:back ?:help C-p:palette o:browser P:scope gh:home gi:issues gm:mrs g/:search gt:timeline diff --git a/crates/lore-tui/tests/snapshots/mr_list_default.snap b/crates/lore-tui/tests/snapshots/mr_list_default.snap new file mode 100644 index 0000000..06f5620 --- /dev/null +++ b/crates/lore-tui/tests/snapshots/mr_list_default.snap @@ -0,0 +1,40 @@ +Dashboard > Merge Requests +/ type / to filter +IID v Title State Author Target Labels Project +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + !42 Implement exponential backoff for HT opened bob main backend infra/platform +!88 [W WIP: Redesign settings page opened alice main ui web/frontend + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Showing 2 of 2 merge requests + NORMAL q:quit esc:back ?:help C-p:palette o:browser P:scope gh:home gi:issues gm:mrs g/:search gt:timeline diff --git a/crates/lore-tui/tests/snapshots/search_results.snap b/crates/lore-tui/tests/snapshots/search_results.snap new file mode 100644 index 0000000..77a4d08 --- /dev/null +++ b/crates/lore-tui/tests/snapshots/search_results.snap @@ -0,0 +1,40 @@ +Dashboard > Search +[ FTS ] > Type to search... +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + No search indexes found. + Run: lore generate-docs && lore embed + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NORMAL q:quit esc:back ?:help C-p:palette o:browser P:scope gh:home gi:issues gm:mrs g/:search gt:timeline