diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d8ba65a..0464d7d 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":"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-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":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:12:14.447996Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:42:38.833054Z","closed_at":"2026-02-19T05:42:38.832902Z","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"}]} @@ -175,7 +175,7 @@ {"id":"bd-2wpf","title":"Ship timeline CLI with human and robot renderers","description":"## Problem\nThe timeline pipeline (5-stage SEED->HYDRATE->EXPAND->COLLECT->RENDER) is implemented but not wired to the CLI. This is one of lore's most unique features — chronological narrative reconstruction from resource events, cross-references, and notes — and it is invisible to users and agents.\n\n## Current State\n- Types defined: src/core/timeline.rs (TimelineEvent, TimelineSeed, etc.)\n- Seed stage: src/core/timeline_seed.rs (FTS search -> seed entities)\n- Expand stage: src/core/timeline_expand.rs (cross-reference expansion)\n- Collect stage: src/core/timeline_collect.rs (event gathering from resource events + notes)\n- CLI command structure: src/cli/commands/timeline.rs (exists but incomplete)\n- Remaining beads: bd-1nf (CLI wiring), bd-2f2 (human renderer), bd-dty (robot renderer)\n\n## Acceptance Criteria\n1. lore timeline 'authentication refactor' works end-to-end:\n - Searches for matching entities (SEED)\n - Fetches raw data (HYDRATE)\n - Expands via cross-references (EXPAND with --depth flag, default 1)\n - Collects events chronologically (COLLECT)\n - Renders human-readable narrative (RENDER)\n2. Human renderer output:\n - Chronological event stream with timestamps\n - Color-coded by event type (state change, label change, note, reference)\n - Actor names with role context\n - Grouped by day/week for readability\n - Evidence snippets from notes (first 200 chars)\n3. Robot renderer output (--robot / -J):\n - JSON array of events with: timestamp, event_type, actor, entity_ref, body/snippet, metadata\n - Seed entities listed separately (what matched the query)\n - Expansion depth metadata (how far from seed)\n - Total event count and time range\n4. CLI flags:\n - --project (scope to project)\n - --since (time range)\n - --depth N (expansion depth, default 1, max 3)\n - --expand-mentions (follow mention references, not just closes/related)\n - -n LIMIT (max events)\n5. Performance: timeline for a single issue with 50 events renders in <200ms\n\n## Relationship to Existing Beads\nThis supersedes/unifies: bd-1nf (CLI wiring), bd-2f2 (human renderer), bd-dty (robot renderer). Those can be closed when this ships.\n\n## Files to Modify\n- src/cli/commands/timeline.rs (CLI wiring, flag parsing, output dispatch)\n- src/core/timeline.rs (may need RENDER stage types)\n- New: src/cli/render/timeline_human.rs or inline in timeline.rs\n- New: src/cli/render/timeline_robot.rs or inline in timeline.rs","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-12T15:46:16.246889Z","created_by":"tayloreernisse","updated_at":"2026-02-12T15:50:43.885226Z","closed_at":"2026-02-12T15:50:43.885180Z","close_reason":"Already implemented: run_timeline(), print_timeline(), print_timeline_json_with_meta(), handle_timeline() all exist and are fully wired. Code audit 2026-02-12.","compaction_level":0,"original_size":0,"labels":["cli","cli-imp"],"dependencies":[{"issue_id":"bd-2wpf","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2x2h","title":"Implement Sync screen (running + summary modes + progress coalescer)","description":"## Background\nThe Sync screen provides real-time progress visualization during data synchronization. The TUI drives sync directly via lore library calls (not subprocess) — this gives direct access to progress callbacks, proper error propagation, and cooperative cancellation via CancelToken. The TUI is the primary human interface; the CLI serves robots/scripts.\n\nAfter sync completes, the screen transitions to a summary view showing exact changed entity counts. A progress coalescer prevents render thrashing by batching rapid progress updates.\n\nDesign principle: the TUI is self-contained. It does NOT detect or react to external CLI sync operations. If someone runs lore sync externally, the TUI's natural re-query on navigation handles stale data implicitly.\n\n## Approach\nCreate state, action, and view modules for the Sync screen:\n\n**State** (crates/lore-tui/src/screen/sync/state.rs):\n- SyncScreenMode enum: FullScreen, Inline (for use from Bootstrap screen)\n- SyncState enum: Idle, Running(SyncProgress), Complete(SyncSummary), Error(String)\n- SyncProgress: per-lane progress (issues, MRs, discussions, notes, events, statuses) with counts and ETA\n- SyncSummary: changed entity counts (new, updated, deleted per type), duration, errors\n- ProgressCoalescer: buffers progress updates, emits at most every 100ms to prevent render thrash\n\n**sync_delta_ledger** (crates/lore-tui/src/screen/sync/delta_ledger.rs):\n- SyncDeltaLedger: in-memory per-run record of changed entity IDs\n- Fields: new_issue_iids (Vec), updated_issue_iids (Vec), new_mr_iids (Vec), updated_mr_iids (Vec)\n- record_change(entity_type, iid, change_kind) — called by sync progress callback\n- summary() -> SyncSummary — produces the final counts for the summary view\n- Purpose: after sync completes, the dashboard and list screens can use the ledger to highlight \"new since last sync\" items\n\n**Action** (crates/lore-tui/src/screen/sync/action.rs):\n- start_sync(db: &DbManager, config: &Config, cancel: CancelToken) -> Cmd\n- Calls lore library ingestion functions directly: ingest_issues, ingest_mrs, ingest_discussions, etc.\n- Progress callback sends Msg::SyncProgress(lane, count, total) via channel\n- On completion sends Msg::SyncComplete(SyncSummary)\n- On cancel sends Msg::SyncCancelled(partial_summary)\n\n**Per-project fault isolation:** If sync for one project fails, continue syncing other projects. Collect per-project errors and display in summary view. Don't abort entire sync on single project failure.\n\n**View** (crates/lore-tui/src/screen/sync/view.rs):\n- Running view: per-lane progress bars with counts/totals, overall ETA, cancel hint (Esc)\n- Stream stats footer: show items/sec throughput for active lanes\n- Summary view: table of entity types with new/updated/deleted columns, total duration, per-project error list\n- Error view: error message with retry option\n- Inline mode: compact single-line progress for embedding in Bootstrap screen\n\nThe Sync screen uses TaskSupervisor for the background sync task with cooperative cancellation.\n\n## Acceptance Criteria\n- [ ] Sync screen launches sync via lore library calls (NOT subprocess)\n- [ ] Per-lane progress bars update in real-time during sync\n- [ ] ProgressCoalescer batches updates to at most 10/second (100ms floor)\n- [ ] Esc cancels sync cooperatively via CancelToken, shows partial summary\n- [ ] Sync completion transitions to summary view with accurate change counts\n- [ ] Summary view shows new/updated/deleted counts per entity type\n- [ ] Error during sync shows error message with retry option\n- [ ] Sync task registered with TaskSupervisor (dedup by TaskKey::Sync)\n- [ ] Per-project fault isolation: single project failure doesn't abort entire sync\n- [ ] SyncDeltaLedger records changed entity IDs for post-sync highlighting\n- [ ] Stream stats footer shows items/sec throughput\n- [ ] ScreenMode::Inline renders compact single-line progress for Bootstrap embedding\n- [ ] Unit tests for ProgressCoalescer batching behavior\n- [ ] Unit tests for SyncDeltaLedger record/summary\n- [ ] Integration test: mock sync with FakeClock verifies progress -> summary transition\n\n## Files\n- CREATE: crates/lore-tui/src/screen/sync/state.rs\n- CREATE: crates/lore-tui/src/screen/sync/action.rs\n- CREATE: crates/lore-tui/src/screen/sync/view.rs\n- CREATE: crates/lore-tui/src/screen/sync/delta_ledger.rs\n- CREATE: crates/lore-tui/src/screen/sync/mod.rs\n- MODIFY: crates/lore-tui/src/screen/mod.rs (add pub mod sync)\n\n## TDD Anchor\nRED: Write test_progress_coalescer_batches_rapid_updates that sends 50 progress updates in 10ms and asserts coalescer emits at most 1.\nGREEN: Implement ProgressCoalescer with configurable floor interval.\nVERIFY: cargo test -p lore-tui sync -- --nocapture\n\nAdditional tests:\n- test_sync_cancel_produces_partial_summary\n- test_sync_complete_produces_full_summary\n- test_sync_error_shows_retry\n- test_sync_dedup_prevents_double_launch\n- test_delta_ledger_records_changes: record 5 new issues and 3 updated MRs, assert summary counts\n- test_per_project_fault_isolation: simulate one project failure, verify others complete\n\n## Edge Cases\n- Sync cancelled immediately after start — partial summary with zero counts is valid\n- Network timeout during sync — error state with last-known progress preserved\n- Very large sync (100k+ entities) — progress coalescer prevents render thrash\n- Sync started while another sync TaskKey::Sync exists — TaskSupervisor dedup rejects it\n- Inline mode from Bootstrap: compact rendering, no full progress bars\n\n## Dependency Context\nUses TaskSupervisor from bd-3le2 for dedup and cancellation. Uses DbManager from bd-2kop for database access. Uses lore library ingestion module directly for sync operations. Used by Bootstrap screen (bd-3ty8) in inline mode.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:09.481354Z","created_by":"tayloreernisse","updated_at":"2026-02-19T04:40:45.881183Z","closed_at":"2026-02-19T04:40:45.881129Z","close_reason":"Completed in previous session: Sync screen view + all 9 message handlers wired.","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2x2h","depends_on_id":"bd-3le2","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2x2h","depends_on_id":"bd-u7se","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2y79","title":"Add work item status via GraphQL enrichment","description":"## Summary\n\nGitLab 18.2+ has native work item status (To do, In progress, Done, Won't do, Duplicate) available ONLY via GraphQL, not REST. This enriches synced issues with status information by making supplementary GraphQL calls after REST ingestion.\n\n**Plan document:** plans/work-item-status-graphql.md\n\n## Critical Findings (from API research)\n\n- **EE-only (Premium/Ultimate)** — Free tier won't have the widget at all\n- **GraphQL auth differs from REST** — must use `Authorization: Bearer `, NOT `PRIVATE-TOKEN`\n- **Must use `workItems` resolver, NOT `project.issues`** — legacy issues path doesn't expose status widgets\n- **5 categories:** TRIAGE, TO_DO, IN_PROGRESS, DONE, CANCELED (not 3 as originally assumed)\n- **Max 100 items per GraphQL page** (standard GitLab limit)\n- **Custom statuses possible on 18.5+** — can't assume only system-defined statuses\n\n## Migration\n\nUses migration **021** (001-020 already exist on disk).\nAdds `status_name TEXT` and `status_category TEXT` to `issues` table (both nullable).\n\n## Files\n\n- src/gitlab/graphql.rs (NEW — minimal GraphQL client + status fetcher)\n- src/gitlab/mod.rs (add pub mod graphql)\n- src/gitlab/types.rs (WorkItemStatus, WorkItemStatusCategory enum)\n- src/core/db.rs (migration 021 in MIGRATIONS array)\n- src/core/config.rs (fetch_work_item_status toggle in SyncConfig)\n- src/ingestion/orchestrator.rs (enrichment step after issue sync)\n- src/cli/commands/show.rs (display status)\n- src/cli/commands/list.rs (status in list output + --status filter)\n\n## Acceptance Criteria\n\n- [ ] GraphQL client POSTs queries with Bearer auth and handles errors\n- [ ] Status fetched via workItems resolver with pagination\n- [ ] Migration 021 adds status_name and status_category to issues\n- [ ] lore show issue displays status (when available)\n- [ ] lore --robot show issue includes status in JSON\n- [ ] lore list issues --status filter works\n- [ ] Graceful degradation: Free tier, old GitLab, disabled GraphQL all handled\n- [ ] Config toggle: fetch_work_item_status (default true)\n- [ ] cargo check + clippy + tests pass","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-05T18:32:39.287957Z","created_by":"tayloreernisse","updated_at":"2026-02-17T15:08:29.499020Z","closed_at":"2026-02-17T15:08:29.498969Z","close_reason":"Already implemented: GraphQL status enrichment shipped in v0.8.x — migration 021, graphql.rs, --status filter, --no-status flag all complete","compaction_level":0,"original_size":0,"labels":["api","phase-b"]} -{"id":"bd-2ygk","title":"Implement user flow integration tests (9 PRD flows)","description":"## Background\n\nThe PRD Section 6 defines 9 end-to-end user flows that exercise cross-screen navigation, state preservation, and data flow. The existing vertical slice test (bd-1mju) covers one flow (Dashboard -> Issue List -> Issue Detail -> Sync). These integration tests cover the remaining 8 flows plus re-test the vertical slice from a user-journey perspective. Each test simulates a realistic keystroke sequence using FrankenTUI's test harness and verifies that the correct screens are reached with the correct data visible.\n\n## Approach\n\nCreate a test module `tests/tui_user_flows.rs` with 9 test functions, each simulating a keystroke sequence against a FrankenTUI `TestHarness` with a pre-populated test database. Tests use `FakeClock` for deterministic timestamps.\n\n**Test database fixture**: A shared setup function creates an in-memory SQLite DB with ~20 issues, ~10 MRs, ~30 discussions, a few experts, and timeline events. This fixture is reused across all flow tests.\n\n**Flow tests**:\n\n1. **`test_flow_find_expert`** — Dashboard -> `w` -> type \"src/auth/\" -> verify Expert mode results appear -> `↓` select first person -> `Enter` -> verify navigation to Issue List filtered by that person\n2. **`test_flow_timeline_query`** — Dashboard -> `t` -> type \"auth timeout\" -> `Enter` -> verify Timeline shows seed events -> `Enter` on first event -> verify entity detail opens -> `Esc` -> back on Timeline\n3. **`test_flow_quick_search`** — Any screen -> `/` -> type query -> verify results appear -> `Tab` (switch mode) -> verify mode label changes -> `Enter` -> verify entity detail opens\n4. **`test_flow_sync_and_browse`** — Dashboard -> `s` -> `Enter` (start sync) -> wait for completion -> verify Summary shows deltas -> `i` -> verify Issue List filtered to new items\n5. **`test_flow_review_workload`** — Dashboard -> `w` -> `Tab` (Workload mode) -> type \"@bjones\" -> verify workload sections appear (assigned, authored, reviewing)\n6. **`test_flow_command_palette`** — Any screen -> `Ctrl+P` -> type \"mrs draft\" -> verify fuzzy match -> `Enter` -> verify MR List opened with draft filter\n7. **`test_flow_morning_triage`** — Dashboard -> `i` -> verify Issue List (opened, sorted by updated) -> `Enter` on first -> verify Issue Detail -> `Esc` -> verify cursor preserved on same row -> `j` -> verify cursor moved\n8. **`test_flow_direct_screen_jumps`** — Issue Detail -> `gt` -> verify Timeline -> `gw` -> verify Who -> `gi` -> verify Issue List -> `H` -> verify Dashboard (clean reset)\n9. **`test_flow_risk_sweep`** — Dashboard -> scroll to Insights -> `Enter` on first insight -> verify pre-filtered Issue List\n\nEach test follows the pattern:\n```rust\n#[test]\nfn test_flow_X() {\n let (harness, app) = setup_test_harness_with_fixture();\n // Send keystrokes\n harness.send_key(Key::Char('w'));\n // Assert screen state\n assert_eq!(app.current_screen(), Screen::Who);\n // Assert visible content\n let frame = harness.render();\n assert!(frame.contains(\"Expert\"));\n}\n```\n\n## Acceptance Criteria\n- [ ] All 9 flow tests exist and compile\n- [ ] Each test uses the shared DB fixture (no per-test DB setup)\n- [ ] Each test verifies screen transitions via `current_screen()` assertions\n- [ ] Each test verifies at least one content assertion (rendered text contains expected data)\n- [ ] test_flow_morning_triage verifies cursor preservation after Enter/Esc round-trip\n- [ ] test_flow_direct_screen_jumps verifies the g-prefix navigation chain\n- [ ] test_flow_sync_and_browse verifies delta-filtered navigation after sync\n- [ ] All tests use FakeClock for deterministic timestamps\n- [ ] Tests complete in <5 seconds each (no real I/O)\n\n## Files\n- CREATE: crates/lore-tui/tests/tui_user_flows.rs\n- MODIFY: (none — this is a new test file only)\n\n## TDD Anchor\nRED: Write `test_flow_morning_triage` first — it exercises the most common daily workflow (Dashboard -> Issue List -> Issue Detail -> back with cursor preservation). Start with just the Dashboard -> Issue List transition.\nGREEN: Requires all Phase 2 core screens to be working; the test itself is the GREEN verification.\nVERIFY: cargo test -p lore-tui test_flow_morning_triage\n\nAdditional tests: All 9 flows listed above.\n\n## Edge Cases\n- Flow tests must handle async data loading — use harness.tick() or harness.wait_for_idle() to let async tasks complete before asserting\n- g-prefix timeout (500ms) — tests must send the second key within the timeout; use harness clock control\n- Sync flow test needs a mock sync that completes quickly — use a pre-populated SyncDeltaLedger rather than running actual sync\n\n## Dependency Context\n- Depends on bd-1mju (vertical slice integration test) which establishes the test harness patterns and fixture setup.\n- Depends on bd-2nfs (snapshot test infrastructure) which provides the FakeClock and TestHarness setup.\n- Depends on all Phase 2 core screen beads (bd-35g5 Dashboard, bd-3ei1 Issue List, bd-8ab7 Issue Detail, bd-2kr0 MR List, bd-3t1b MR Detail) being implemented.\n- Depends on Phase 3 power feature beads (bd-1zow Search, bd-29qw Timeline, bd-u7se Who, bd-wzqi Command Palette) being implemented.\n- Depends on bd-2x2h (Sync screen) for the sync+browse flow test.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:41.060826Z","created_by":"tayloreernisse","updated_at":"2026-02-12T19:29:52.743563Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2ygk","depends_on_id":"bd-1mju","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-1zow","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-29qw","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-2kr0","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-2nfs","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-2x2h","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-35g5","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-3ei1","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-3t1b","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-8ab7","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-u7se","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-wzqi","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} +{"id":"bd-2ygk","title":"Implement user flow integration tests (9 PRD flows)","description":"## Background\n\nThe PRD Section 6 defines 9 end-to-end user flows that exercise cross-screen navigation, state preservation, and data flow. The existing vertical slice test (bd-1mju) covers one flow (Dashboard -> Issue List -> Issue Detail -> Sync). These integration tests cover the remaining 8 flows plus re-test the vertical slice from a user-journey perspective. Each test simulates a realistic keystroke sequence using FrankenTUI's test harness and verifies that the correct screens are reached with the correct data visible.\n\n## Approach\n\nCreate a test module `tests/tui_user_flows.rs` with 9 test functions, each simulating a keystroke sequence against a FrankenTUI `TestHarness` with a pre-populated test database. Tests use `FakeClock` for deterministic timestamps.\n\n**Test database fixture**: A shared setup function creates an in-memory SQLite DB with ~20 issues, ~10 MRs, ~30 discussions, a few experts, and timeline events. This fixture is reused across all flow tests.\n\n**Flow tests**:\n\n1. **`test_flow_find_expert`** — Dashboard -> `w` -> type \"src/auth/\" -> verify Expert mode results appear -> `↓` select first person -> `Enter` -> verify navigation to Issue List filtered by that person\n2. **`test_flow_timeline_query`** — Dashboard -> `t` -> type \"auth timeout\" -> `Enter` -> verify Timeline shows seed events -> `Enter` on first event -> verify entity detail opens -> `Esc` -> back on Timeline\n3. **`test_flow_quick_search`** — Any screen -> `/` -> type query -> verify results appear -> `Tab` (switch mode) -> verify mode label changes -> `Enter` -> verify entity detail opens\n4. **`test_flow_sync_and_browse`** — Dashboard -> `s` -> `Enter` (start sync) -> wait for completion -> verify Summary shows deltas -> `i` -> verify Issue List filtered to new items\n5. **`test_flow_review_workload`** — Dashboard -> `w` -> `Tab` (Workload mode) -> type \"@bjones\" -> verify workload sections appear (assigned, authored, reviewing)\n6. **`test_flow_command_palette`** — Any screen -> `Ctrl+P` -> type \"mrs draft\" -> verify fuzzy match -> `Enter` -> verify MR List opened with draft filter\n7. **`test_flow_morning_triage`** — Dashboard -> `i` -> verify Issue List (opened, sorted by updated) -> `Enter` on first -> verify Issue Detail -> `Esc` -> verify cursor preserved on same row -> `j` -> verify cursor moved\n8. **`test_flow_direct_screen_jumps`** — Issue Detail -> `gt` -> verify Timeline -> `gw` -> verify Who -> `gi` -> verify Issue List -> `H` -> verify Dashboard (clean reset)\n9. **`test_flow_risk_sweep`** — Dashboard -> scroll to Insights -> `Enter` on first insight -> verify pre-filtered Issue List\n\nEach test follows the pattern:\n```rust\n#[test]\nfn test_flow_X() {\n let (harness, app) = setup_test_harness_with_fixture();\n // Send keystrokes\n harness.send_key(Key::Char('w'));\n // Assert screen state\n assert_eq!(app.current_screen(), Screen::Who);\n // Assert visible content\n let frame = harness.render();\n assert!(frame.contains(\"Expert\"));\n}\n```\n\n## Acceptance Criteria\n- [ ] All 9 flow tests exist and compile\n- [ ] Each test uses the shared DB fixture (no per-test DB setup)\n- [ ] Each test verifies screen transitions via `current_screen()` assertions\n- [ ] Each test verifies at least one content assertion (rendered text contains expected data)\n- [ ] test_flow_morning_triage verifies cursor preservation after Enter/Esc round-trip\n- [ ] test_flow_direct_screen_jumps verifies the g-prefix navigation chain\n- [ ] test_flow_sync_and_browse verifies delta-filtered navigation after sync\n- [ ] All tests use FakeClock for deterministic timestamps\n- [ ] Tests complete in <5 seconds each (no real I/O)\n\n## Files\n- CREATE: crates/lore-tui/tests/tui_user_flows.rs\n- MODIFY: (none — this is a new test file only)\n\n## TDD Anchor\nRED: Write `test_flow_morning_triage` first — it exercises the most common daily workflow (Dashboard -> Issue List -> Issue Detail -> back with cursor preservation). Start with just the Dashboard -> Issue List transition.\nGREEN: Requires all Phase 2 core screens to be working; the test itself is the GREEN verification.\nVERIFY: cargo test -p lore-tui test_flow_morning_triage\n\nAdditional tests: All 9 flows listed above.\n\n## Edge Cases\n- Flow tests must handle async data loading — use harness.tick() or harness.wait_for_idle() to let async tasks complete before asserting\n- g-prefix timeout (500ms) — tests must send the second key within the timeout; use harness clock control\n- Sync flow test needs a mock sync that completes quickly — use a pre-populated SyncDeltaLedger rather than running actual sync\n\n## Dependency Context\n- Depends on bd-1mju (vertical slice integration test) which establishes the test harness patterns and fixture setup.\n- Depends on bd-2nfs (snapshot test infrastructure) which provides the FakeClock and TestHarness setup.\n- Depends on all Phase 2 core screen beads (bd-35g5 Dashboard, bd-3ei1 Issue List, bd-8ab7 Issue Detail, bd-2kr0 MR List, bd-3t1b MR Detail) being implemented.\n- Depends on Phase 3 power feature beads (bd-1zow Search, bd-29qw Timeline, bd-u7se Who, bd-wzqi Command Palette) being implemented.\n- Depends on bd-2x2h (Sync screen) for the sync+browse flow test.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:41.060826Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:52:27.985177Z","closed_at":"2026-02-19T05:52:27.985124Z","close_reason":"9 user flow integration tests implemented and passing: morning triage, direct screen jumps, quick search, sync and browse, expert navigation, command palette, timeline navigation, bootstrap sync, MR drill-in","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2ygk","depends_on_id":"bd-1mju","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-1zow","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-29qw","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-2kr0","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-2nfs","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-2x2h","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-35g5","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-3ei1","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-3t1b","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-8ab7","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-u7se","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2ygk","depends_on_id":"bd-wzqi","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2yo","title":"Fetch MR diffs API and populate mr_file_changes","description":"## Background\n\nThis bead fetches MR diff metadata from the GitLab API and populates the mr_file_changes table created by migration 016. It extracts only file-level metadata (paths, change type) and discards actual diff content.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.3 (Ingestion).\n\n## Codebase Context\n\n- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)\n- dependent_queue.rs has: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff\n- Orchestrator pattern: enqueue after entity ingestion, drain after primary ingestion completes\n- GitLab client uses fetch_all_pages() for pagination\n- Existing drain patterns in orchestrator.rs: drain_resource_events() and drain_mr_closes_issues() — follow same pattern\n- config.sync.fetch_mr_file_changes flag guards enqueue (see bd-jec)\n- mr_file_changes table created by migration 016 (bd-1oo) — NOT 015 (015 is commit SHAs)\n- merge_commit_sha and squash_commit_sha already captured during MR ingestion (src/ingestion/merge_requests.rs lines 184, 205-206, 230-231) — no work needed for those fields\n\n## Approach\n\n### 1. API Client — add to `src/gitlab/client.rs`:\n\n```rust\npub async fn fetch_mr_diffs(\n &self,\n project_id: i64,\n mr_iid: i64,\n) -> Result> {\n let path = format\\!(\"/projects/{project_id}/merge_requests/{mr_iid}/diffs\");\n self.fetch_all_pages(&path, &[(\"per_page\", \"100\")]).await\n .or_else(|e| coalesce_not_found(e, Vec::new()))\n}\n```\n\n### 2. Types — add to `src/gitlab/types.rs`:\n\n```rust\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMrDiff {\n pub old_path: String,\n pub new_path: String,\n pub new_file: bool,\n pub renamed_file: bool,\n pub deleted_file: bool,\n // Ignore: diff, a_mode, b_mode, generated_file (not stored)\n}\n```\n\nAdd `GitLabMrDiff` to `src/gitlab/mod.rs` re-exports.\n\n### 3. Change Type Derivation (in new file):\n\n```rust\nfn derive_change_type(diff: &GitLabMrDiff) -> &'static str {\n if diff.new_file { \"added\" }\n else if diff.renamed_file { \"renamed\" }\n else if diff.deleted_file { \"deleted\" }\n else { \"modified\" }\n}\n```\n\n### 4. DB Storage — new `src/ingestion/mr_diffs.rs`:\n\n```rust\npub fn upsert_mr_file_changes(\n conn: &Connection,\n mr_local_id: i64,\n project_id: i64,\n diffs: &[GitLabMrDiff],\n) -> Result {\n // DELETE FROM mr_file_changes WHERE merge_request_id = ?\n // INSERT each diff row with derived change_type\n // DELETE+INSERT is simpler than UPSERT for array replacement\n}\n```\n\nAdd `pub mod mr_diffs;` to `src/ingestion/mod.rs`.\n\n### 5. Queue Integration — in orchestrator.rs:\n\n```rust\n// After MR upsert, if config.sync.fetch_mr_file_changes:\nenqueue_job(conn, project_id, \"merge_request\", mr_iid, mr_local_id, \"mr_diffs\")?;\n```\n\nAdd `drain_mr_diffs()` following the drain_mr_closes_issues() pattern. Call it after drain_mr_closes_issues() in the sync pipeline.\n\n## Acceptance Criteria\n\n- [ ] `fetch_mr_diffs()` calls GET /projects/:id/merge_requests/:iid/diffs with pagination\n- [ ] GitLabMrDiff type added to src/gitlab/types.rs and re-exported from src/gitlab/mod.rs\n- [ ] Change type derived: new_file->added, renamed_file->renamed, deleted_file->deleted, else->modified\n- [ ] mr_file_changes rows have correct old_path, new_path, change_type\n- [ ] Old rows deleted before insert (clean replacement per MR)\n- [ ] Jobs only enqueued when config.sync.fetch_mr_file_changes is true\n- [ ] 404/403 API errors handled gracefully (empty result, not failure)\n- [ ] drain_mr_diffs() added to orchestrator.rs sync pipeline\n- [ ] `pub mod mr_diffs;` added to src/ingestion/mod.rs\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/gitlab/client.rs` (add fetch_mr_diffs method)\n- `src/gitlab/types.rs` (add GitLabMrDiff struct)\n- `src/gitlab/mod.rs` (re-export GitLabMrDiff)\n- `src/ingestion/mr_diffs.rs` (NEW — upsert_mr_file_changes + derive_change_type)\n- `src/ingestion/mod.rs` (add pub mod mr_diffs)\n- `src/ingestion/orchestrator.rs` (enqueue mr_diffs jobs + drain_mr_diffs)\n\n## TDD Loop\n\nRED:\n- `test_derive_change_type_added` - new_file=true -> \"added\"\n- `test_derive_change_type_renamed` - renamed_file=true -> \"renamed\"\n- `test_derive_change_type_deleted` - deleted_file=true -> \"deleted\"\n- `test_derive_change_type_modified` - all false -> \"modified\"\n- `test_upsert_replaces_existing` - second upsert replaces first\n\nGREEN: Implement API client, type derivation, DB ops, orchestrator wiring.\n\nVERIFY: `cargo test --lib -- mr_diffs`\n\n## Edge Cases\n\n- MR with 500+ files: paginate properly via fetch_all_pages\n- Binary files: handled as modified (renamed_file/new_file/deleted_file all false)\n- File renamed AND modified: renamed_file=true takes precedence\n- Draft MRs: still fetch diffs\n- Deleted MR: 404 -> empty vec via coalesce_not_found()\n- merge_commit_sha/squash_commit_sha: already handled in merge_requests.rs ingestion — NOT part of this bead\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:08.939514Z","created_by":"tayloreernisse","updated_at":"2026-02-08T18:27:05.993580Z","closed_at":"2026-02-08T18:27:05.993482Z","close_reason":"Implemented: GitLabMrDiff type, fetch_mr_diffs client method, upsert_mr_file_changes in new mr_diffs.rs module, enqueue_mr_diffs_jobs + drain_mr_diffs in orchestrator, migration 020 for diffs_synced_for_updated_at watermark, progress events, autocorrect registry. All 390 tests pass, clippy clean.","compaction_level":0,"original_size":0,"labels":["api","gate-4","phase-b"],"dependencies":[{"issue_id":"bd-2yo","depends_on_id":"bd-14q","type":"parent-child","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2yo","depends_on_id":"bd-1oo","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2yo","depends_on_id":"bd-jec","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"},{"issue_id":"bd-2yo","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-02-12T19:34:39Z","created_by":"import"}]} {"id":"bd-2yq","title":"[CP1] Issue transformer with label extraction","description":"Transform GitLab issue payloads to normalized database schema.\n\nFunctions to implement:\n- transformIssue(gitlabIssue, localProjectId) → NormalizedIssue\n- extractLabels(gitlabIssue, localProjectId) → Label[]\n\nTransformation rules:\n- Convert ISO timestamps to ms epoch using isoToMs()\n- Set last_seen_at to nowMs()\n- Handle labels vs labels_details (prefer details when available)\n- Handle missing optional fields gracefully\n\nFiles: src/gitlab/transformers/issue.ts\nTests: tests/unit/issue-transformer.test.ts\nDone when: Unit tests pass for payload transformation and label extraction","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:09.660448Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.152259Z","closed_at":"2026-01-25T15:21:35.152259Z","deleted_at":"2026-01-25T15:21:35.152254Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2ys","title":"[CP1] Cargo.toml updates - async-stream and futures","description":"## Background\n\nThe GitLab client pagination methods require async streaming capabilities. The `async-stream` crate provides the `stream!` macro for creating async iterators, and `futures` provides `StreamExt` for consuming them with `.next()` and other combinators.\n\n## Approach\n\nAdd these dependencies to Cargo.toml:\n\n```toml\n[dependencies]\nasync-stream = \"0.3\"\nfutures = { version = \"0.3\", default-features = false, features = [\"alloc\"] }\n```\n\nUse minimal features on `futures` to avoid pulling unnecessary code.\n\n## Acceptance Criteria\n\n- [ ] `async-stream = \"0.3\"` is in Cargo.toml [dependencies]\n- [ ] `futures` with `alloc` feature is in Cargo.toml [dependencies]\n- [ ] `cargo check` succeeds after adding dependencies\n\n## Files\n\n- Cargo.toml (edit)\n\n## TDD Loop\n\nRED: Not applicable (dependency addition)\nGREEN: Add lines to Cargo.toml\nVERIFY: `cargo check`\n\n## Edge Cases\n\n- If `futures` is already present, merge features rather than duplicate\n- Use exact version pins for reproducibility","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.104664Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:25:10.274787Z","closed_at":"2026-01-25T22:25:10.274727Z","close_reason":"Added async-stream 0.3 and futures 0.3 (alloc feature) to Cargo.toml, cargo check passes","compaction_level":0,"original_size":0} @@ -249,7 +249,7 @@ {"id":"bd-3qs","title":"Implement lore generate-docs CLI command","description":"## Background\nThe generate-docs CLI command is the user-facing wrapper around the document regeneration pipeline. It has two modes: incremental (default, processes dirty_sources queue only) and full (seeds dirty_sources with ALL entities, then drains). Both modes use the same regenerator codepath to avoid logic divergence. Full mode uses keyset pagination (WHERE id > last_id) for seeding to avoid O(n^2) OFFSET degradation on large tables.\n\n## Approach\nCreate `src/cli/commands/generate_docs.rs` per PRD Section 2.4.\n\n**Core function:**\n```rust\npub fn run_generate_docs(\n config: &Config,\n full: bool,\n project_filter: Option<&str>,\n) -> Result\n```\n\n**Full mode seeding (keyset pagination):**\n```rust\nconst FULL_MODE_CHUNK_SIZE: usize = 2000;\n\n// For each source type (issues, MRs, discussions):\nlet mut last_id: i64 = 0;\nloop {\n let tx = conn.transaction()?;\n let inserted = tx.execute(\n \"INSERT INTO dirty_sources (source_type, source_id, queued_at, ...)\n SELECT 'issue', id, ?, 0, NULL, NULL, NULL\n FROM issues WHERE id > ? ORDER BY id LIMIT ?\n ON CONFLICT(source_type, source_id) DO NOTHING\",\n params![now_ms(), last_id, FULL_MODE_CHUNK_SIZE],\n )?;\n if inserted == 0 { tx.commit()?; break; }\n // Advance keyset cursor...\n tx.commit()?;\n}\n```\n\n**After draining (full mode only):**\n```sql\nINSERT INTO documents_fts(documents_fts) VALUES('optimize')\n```\n\n**CLI args:**\n```rust\n#[derive(Args)]\npub struct GenerateDocsArgs {\n #[arg(long)]\n full: bool,\n #[arg(long)]\n project: Option,\n}\n```\n\n**Output:** Human-readable table + JSON robot mode.\n\n## Acceptance Criteria\n- [ ] Default mode (no --full): processes only existing dirty_sources entries\n- [ ] --full mode: seeds dirty_sources with ALL issues, MRs, and discussions\n- [ ] Full mode uses keyset pagination (WHERE id > last_id, not OFFSET)\n- [ ] Full mode chunk size is 2000\n- [ ] Full mode does FTS optimize after completion\n- [ ] Both modes use regenerate_dirty_documents() (same codepath)\n- [ ] Progress bar shown in human mode (via indicatif)\n- [ ] JSON output in robot mode with GenerateDocsResult\n- [ ] GenerateDocsResult has issues/mrs/discussions/total/truncated/skipped counts\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/cli/commands/generate_docs.rs` — new file\n- `src/cli/commands/mod.rs` — add `pub mod generate_docs;`\n- `src/cli/mod.rs` — add GenerateDocsArgs, wire up generate-docs subcommand\n- `src/main.rs` — add generate-docs command handler\n\n## TDD Loop\nRED: Integration test with seeded DB\nGREEN: Implement run_generate_docs with seeding + drain\nVERIFY: `cargo build && cargo test generate_docs`\n\n## Edge Cases\n- Empty database (no issues/MRs/discussions): full mode seeds nothing, returns all-zero counts\n- --project filter in full mode: only seed dirty_sources for entities in that project\n- Interrupted full mode: dirty_sources entries persist (ON CONFLICT DO NOTHING), resume by re-running\n- FTS optimize on empty FTS table: no-op (safe)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:25:55.226666Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:49:23.397157Z","closed_at":"2026-01-30T17:49:23.397098Z","close_reason":"Implemented generate-docs command with incremental + full mode, keyset pagination seeding, FTS optimize, project filter, human + JSON output. Builds clean.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3qs","depends_on_id":"bd-1u1","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3qs","depends_on_id":"bd-221","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-3rjw","title":"Implement entity cache for near-instant detail view reopens","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T04:52:57.031699Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:25:04.479323Z","closed_at":"2026-02-19T05:25:04.479212Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3rjw","depends_on_id":"bd-3t6r","type":"blocks","created_at":"2026-02-19T04:53:03.010140Z","created_by":"tayloreernisse"}]} {"id":"bd-3rl","title":"Epic: Gate C - Sync MVP","description":"## Background\nGate C adds the sync orchestrator and queue infrastructure that makes the search pipeline incremental and self-maintaining. It introduces dirty source tracking (change detection during ingestion), the discussion fetch queue, and the unified lore sync command that orchestrates the full pipeline. Gate C also adds integrity checks and repair paths.\n\n## Gate C Deliverables\n1. Orchestrated lore sync command with incremental doc regen + re-embedding\n2. Integrity checks + repair paths for FTS/embeddings consistency\n\n## Bead Dependencies (execution order, after Gate A)\n1. **bd-mem** — Shared backoff utility (no deps, shared with Gate B)\n2. **bd-38q** — Dirty source tracking (blocked by bd-36p, bd-hrs, bd-mem)\n3. **bd-1je** — Discussion queue (blocked by bd-hrs, bd-mem)\n4. **bd-1i2** — Integrate dirty tracking into ingestion (blocked by bd-38q)\n5. **bd-1x6** — Sync CLI (blocked by bd-38q, bd-1je, bd-1i2, bd-3qs, bd-2sx)\n\n## Acceptance Criteria\n- [ ] `lore sync` runs full pipeline: ingest -> generate-docs -> embed\n- [ ] `lore sync --full` does full re-sync + regeneration\n- [ ] `lore sync --no-embed` skips embedding stage\n- [ ] Dirty tracking: upserted entities automatically marked for regeneration\n- [ ] Queue draining: dirty_sources fully drained in bounded batch loop\n- [ ] Backoff: failed items use exponential backoff with jitter\n- [ ] `lore stats --check` detects inconsistencies\n- [ ] `lore stats --repair` fixes FTS/embedding inconsistencies","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-30T15:25:13.494698Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:05:52.121666Z","closed_at":"2026-01-30T18:05:52.121619Z","close_reason":"All Gate C sub-beads complete: backoff utility, dirty tracking, discussion queue, ingestion integration, sync CLI, stats CLI","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3rl","depends_on_id":"bd-1x6","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-3rl","depends_on_id":"bd-pr1","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} -{"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-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":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:14:19.449695Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:45:44.958139Z","closed_at":"2026-02-19T05:45:44.958025Z","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":"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"}]} @@ -317,7 +317,7 @@ {"id":"bd-sqw","title":"Add Resource Events API endpoints to GitLab client","description":"## Background\nNeed paginated fetching of state/label/milestone events per entity from GitLab Resource Events APIs. The existing client uses reqwest with rate limiting and has stream_issues/stream_merge_requests patterns for paginated endpoints. However, resource events are per-entity (not project-wide), so they should return Vec rather than use streaming.\n\nExisting pagination pattern in client.rs: follow Link headers with per_page=100.\n\n## Approach\nAdd to src/gitlab/client.rs a generic helper and 6 endpoint methods:\n\n1. Generic paginated fetch helper (if not already present):\n```rust\nasync fn fetch_all_pages(&self, url: &str) -> Result> {\n let mut results = Vec::new();\n let mut next_url = Some(url.to_string());\n while let Some(current_url) = next_url {\n self.rate_limiter.lock().unwrap().wait();\n let resp = self.client.get(¤t_url)\n .header(\"PRIVATE-TOKEN\", &self.token)\n .query(&[(\"per_page\", \"100\")])\n .send().await?;\n // ... parse Link header for next page\n let page: Vec = resp.json().await?;\n results.extend(page);\n next_url = parse_next_link(&resp_headers);\n }\n Ok(results)\n}\n```\n\n2. Six endpoint methods:\n```rust\npub async fn fetch_issue_state_events(&self, project_id: i64, iid: i64) -> Result>\npub async fn fetch_issue_label_events(&self, project_id: i64, iid: i64) -> Result>\npub async fn fetch_issue_milestone_events(&self, project_id: i64, iid: i64) -> Result>\npub async fn fetch_mr_state_events(&self, project_id: i64, iid: i64) -> Result>\npub async fn fetch_mr_label_events(&self, project_id: i64, iid: i64) -> Result>\npub async fn fetch_mr_milestone_events(&self, project_id: i64, iid: i64) -> Result>\n```\n\nURL patterns:\n- Issues: `/api/v4/projects/{project_id}/issues/{iid}/resource_{type}_events`\n- MRs: `/api/v4/projects/{project_id}/merge_requests/{iid}/resource_{type}_events`\n\n3. Consider a convenience method that fetches all 3 event types for an entity in one call:\n```rust\npub async fn fetch_all_resource_events(&self, project_id: i64, entity_type: &str, iid: i64) \n -> Result<(Vec, Vec, Vec)>\n```\n\n## Acceptance Criteria\n- [ ] All 6 endpoints construct correct URLs\n- [ ] Pagination follows Link headers (handles entities with >100 events)\n- [ ] Rate limiter respected for each page request\n- [ ] 404 returns GitLabNotFound error (entity may have been deleted)\n- [ ] Network errors wrapped in GitLabNetworkError\n- [ ] Types from bd-2fm used for deserialization\n\n## Files\n- src/gitlab/client.rs (add methods + optionally generic helper)\n\n## TDD Loop\nRED: Add to tests/gitlab_client_tests.rs (or new file):\n- `test_fetch_issue_state_events_url` - verify URL construction (mock or inspect)\n- `test_fetch_mr_label_events_url` - verify URL construction\n- Note: Full integration tests require a mock HTTP server (mockito or wiremock). If the project doesn't already have one, write URL-construction unit tests only.\n\nGREEN: Implement the 6 methods using the generic helper\n\nVERIFY: `cargo test gitlab_client -- --nocapture && cargo build`\n\n## Edge Cases\n- project_id here is the GitLab project ID (not local DB id) — callers must pass gitlab_project_id\n- Empty results (new entity with no events) should return Ok(Vec::new()), not error\n- GitLab returns 403 for projects where Resource Events API is disabled — map to appropriate error\n- Very old entities may have thousands of events — pagination is essential\n- Rate limiter must be called per-page, not per-entity","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:24.137296Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:19:18.432602Z","closed_at":"2026-02-03T16:19:18.432559Z","close_reason":"Added fetch_all_pages generic paginator, 6 per-entity endpoint methods (state/label/milestone for issues and MRs), and fetch_all_resource_events convenience method in src/gitlab/client.rs.","compaction_level":0,"original_size":0,"labels":["api","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-sqw","depends_on_id":"bd-2fm","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-sqw","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-tfh3","title":"WHO: Comprehensive test suite","description":"## Background\n\n20+ tests covering mode resolution, path query construction, SQL queries, and edge cases. All tests use in-memory SQLite with run_migrations().\n\n## Approach\n\n### Test helpers (shared across all tests):\n```rust\nfn setup_test_db() -> Connection {\n let conn = create_connection(Path::new(\":memory:\")).unwrap();\n run_migrations(&conn).unwrap();\n conn\n}\nfn insert_project(conn, id, path) // gitlab_project_id=id*100, web_url from path\nfn insert_mr(conn, id, project_id, iid, author, state) // gitlab_id=id*10, timestamps=now_ms()\nfn insert_issue(conn, id, project_id, iid, author) // state='opened'\nfn insert_discussion(conn, id, project_id, mr_id, issue_id, resolvable, resolved)\n#[allow(clippy::too_many_arguments)]\nfn insert_diffnote(conn, id, discussion_id, project_id, author, file_path, body)\nfn insert_assignee(conn, issue_id, username)\nfn insert_reviewer(conn, mr_id, username)\n```\n\n### Test list with key assertions:\n\n**Mode resolution:**\n- test_is_file_path_discrimination: src/auth/ -> Expert, asmith -> Workload, @asmith -> Workload, asmith+--reviews -> Reviews, --path README.md -> Expert, --path Makefile -> Expert\n\n**Path queries:**\n- test_build_path_query: trailing/ -> prefix, no-dot-no-slash -> prefix, file.ext -> exact, root.md -> exact, .github/workflows/ -> prefix, v1.2/auth/ -> prefix, test_files/ -> escaped prefix\n- test_build_path_query_exact_does_not_escape: README_with_underscore.md -> raw (no \\\\_)\n- test_path_flag_dotless_root_file_is_exact: Makefile -> exact, Dockerfile -> exact\n- test_build_path_query_dotless_subdir_file_uses_db_probe: src/Dockerfile with DB data -> exact; without -> prefix\n- test_build_path_query_probe_is_project_scoped: data in proj 1, unscoped -> exact; scoped proj 2 -> prefix; scoped proj 1 -> exact\n- test_escape_like: normal->normal, has_underscore->has\\\\_underscore, has%percent->has\\\\%percent\n- test_normalize_repo_path: ./src/ -> src/, /src/ -> src/, ././src -> src, backslash conversion, // collapse, whitespace trim\n\n**Queries:**\n- test_expert_query: 3 experts ranked correctly, reviewer_b first\n- test_expert_excludes_self_review_notes: author_a review_mr_count=0, author_mr_count>0\n- test_expert_truncation: limit=2 truncated=true len=2; limit=10 truncated=false\n- test_workload_query: assigned_issues.len()=1, authored_mrs.len()=1\n- test_reviews_query: total=3, categorized=2, categories.len()=2\n- test_normalize_review_prefix: suggestion/Suggestion:/nit/nitpick/non-blocking/TODO\n- test_active_query: total=1, discussions.len()=1, note_count=2 (NOT 1), discussion_id>0\n- test_active_participants_sorted: [\"alpha_user\", \"zebra_user\"]\n- test_overlap_dual_roles: A+R role, both touch counts >0, mr_refs contain project path\n- test_overlap_multi_project_mr_refs: team/backend!100 AND team/frontend!100 present\n- test_overlap_excludes_self_review_notes: review_touch_count=0\n- test_lookup_project_path: round-trip \"team/backend\"\n\n## Files\n\n- `src/cli/commands/who.rs` (inside #[cfg(test)] mod tests)\n\n## TDD Loop\n\nTests are written alongside each query bead (RED phase). This bead tracks the full test suite as a verification gate.\nVERIFY: `cargo test -- who`\n\n## Acceptance Criteria\n\n- [ ] All 20+ tests pass\n- [ ] cargo test -- who shows 0 failures\n- [ ] No clippy warnings from test code (use #[allow(clippy::too_many_arguments)] on insert_diffnote)\n\n## Edge Cases\n\n- In-memory DB includes migration 017 (indexes created but no real data perf benefit)\n- Test timestamps use now_ms() — tests are time-independent (since_ms=0 in most queries)\n- insert_mr uses gitlab_id=id*10 to avoid conflicts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:41:25.839065Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.601284Z","closed_at":"2026-02-08T04:10:29.601248Z","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-tfh3","depends_on_id":"bd-1rdi","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-2711","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-3mj2","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-b51e","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-m7k1","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-s3rc","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-zqpf","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-tir","title":"Implement generic dependent fetch queue (enqueue + drain)","description":"## Background\nThe pending_dependent_fetches table (migration 011) provides a generic job queue for all dependent resource fetches across Gates 1, 2, and 4. This module implements the queue operations: enqueue, claim, complete, fail, and stale lock reclamation. It generalizes the existing discussion_queue.rs pattern.\n\n## Approach\nCreate src/core/dependent_queue.rs with:\n\n```rust\nuse rusqlite::Connection;\nuse super::error::Result;\n\n/// A pending job from the dependent fetch queue.\npub struct PendingJob {\n pub id: i64,\n pub project_id: i64,\n pub entity_type: String, // \"issue\" | \"merge_request\"\n pub entity_iid: i64,\n pub entity_local_id: i64,\n pub job_type: String, // \"resource_events\" | \"mr_closes_issues\" | \"mr_diffs\"\n pub payload_json: Option,\n pub attempts: i32,\n}\n\n/// Enqueue a dependent fetch job. Idempotent via UNIQUE constraint (INSERT OR IGNORE).\npub fn enqueue_job(\n conn: &Connection,\n project_id: i64,\n entity_type: &str,\n entity_iid: i64,\n entity_local_id: i64,\n job_type: &str,\n payload_json: Option<&str>,\n) -> Result // returns true if actually inserted (not deduped)\n\n/// Claim a batch of jobs for processing. Atomically sets locked_at.\n/// Only claims jobs where locked_at IS NULL AND (next_retry_at IS NULL OR next_retry_at <= now).\npub fn claim_jobs(\n conn: &Connection,\n job_type: &str,\n batch_size: usize,\n) -> Result>\n\n/// Mark a job as complete (DELETE the row).\npub fn complete_job(conn: &Connection, job_id: i64) -> Result<()>\n\n/// Mark a job as failed. Increment attempts, set next_retry_at with exponential backoff, clear locked_at.\n/// Backoff: 30s * 2^(attempts-1), capped at 480s.\npub fn fail_job(conn: &Connection, job_id: i64, error: &str) -> Result<()>\n\n/// Reclaim stale locks (locked_at older than threshold).\n/// Returns count of reclaimed jobs.\npub fn reclaim_stale_locks(conn: &Connection, stale_threshold_minutes: u32) -> Result\n\n/// Count pending jobs by job_type (for stats/progress).\npub fn count_pending_jobs(conn: &Connection) -> Result>\n```\n\nRegister in src/core/mod.rs: `pub mod dependent_queue;`\n\n**Key implementation details:**\n- claim_jobs uses a two-step approach: SELECT ids WHERE available, then UPDATE SET locked_at for those ids. Use a single transaction.\n- enqueued_at = current time in ms epoch UTC\n- locked_at = current time in ms epoch UTC when claimed\n- Backoff formula: next_retry_at = now + min(30_000 * 2^(attempts-1), 480_000) ms\n\n## Acceptance Criteria\n- [ ] enqueue_job is idempotent (INSERT OR IGNORE on UNIQUE constraint)\n- [ ] enqueue_job returns true on insert, false on dedup\n- [ ] claim_jobs only claims unlocked, non-retrying jobs\n- [ ] claim_jobs respects batch_size limit\n- [ ] complete_job DELETEs the row\n- [ ] fail_job increments attempts, sets next_retry_at, clears locked_at, records last_error\n- [ ] Backoff: 30s, 60s, 120s, 240s, 480s (capped)\n- [ ] reclaim_stale_locks clears locked_at for jobs older than threshold\n- [ ] count_pending_jobs returns accurate counts by job_type\n\n## Files\n- src/core/dependent_queue.rs (new)\n- src/core/mod.rs (add `pub mod dependent_queue;`)\n\n## TDD Loop\nRED: tests/dependent_queue_tests.rs (new):\n- `test_enqueue_job_basic` - enqueue a job, verify it exists\n- `test_enqueue_job_idempotent` - enqueue same job twice, verify single row\n- `test_claim_jobs_batch` - enqueue 5, claim 3, verify 3 returned and locked\n- `test_claim_jobs_skips_locked` - lock a job, claim again, verify it's skipped\n- `test_claim_jobs_respects_retry_at` - set next_retry_at in future, verify skipped\n- `test_claim_jobs_includes_retryable` - set next_retry_at in past, verify claimed\n- `test_complete_job_deletes` - complete a job, verify gone\n- `test_fail_job_backoff` - fail 3 times, verify exponential next_retry_at values\n- `test_reclaim_stale_locks` - set old locked_at, reclaim, verify cleared\n\nSetup: create_test_db() with migrations 001-011, seed project + issue.\n\nGREEN: Implement all functions\n\nVERIFY: `cargo test dependent_queue -- --nocapture`\n\n## Edge Cases\n- claim_jobs with batch_size=0 should return empty vec (not error)\n- enqueue_job with invalid job_type will be rejected by CHECK constraint — map rusqlite error to LoreError\n- fail_job on a non-existent job_id should be a no-op (job may have been completed by another path)\n- reclaim_stale_locks with 0 threshold would reclaim everything — ensure threshold is reasonable (minimum 1 min)\n- Timestamps must use consistent ms epoch UTC (not seconds)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:57.290181Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:19:14.222626Z","closed_at":"2026-02-03T16:19:14.222579Z","close_reason":"Implemented PendingJob struct, enqueue_job, claim_jobs, complete_job, fail_job (with exponential backoff), reclaim_stale_locks, count_pending_jobs in src/core/dependent_queue.rs.","compaction_level":0,"original_size":0,"labels":["gate-1","phase-b","queue"],"dependencies":[{"issue_id":"bd-tir","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-tir","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} -{"id":"bd-tiux","title":"Add sync_runs migration 027 for surgical mode columns","description":"## Background\nThe `sync_runs` table (created in migration 001, enriched in 014) tracks sync run lifecycle for observability and crash recovery. Surgical sync needs additional columns to track its distinct mode, phase progression, IID targeting, and per-stage counters. This is a schema-only change — no Rust struct changes beyond registering the migration SQL file.\n\nThe migration system uses a `MIGRATIONS` array in `src/core/db.rs`. Each entry is a `(version, sql_file_name)` tuple. SQL files live in `src/core/migrations/`. The current latest migration is 026 (`026_scoring_indexes.sql`), so this will be migration 027. `LATEST_SCHEMA_VERSION` is computed as `MIGRATIONS.len() as i32` and automatically becomes 27.\n\n## Approach\n\n### Step 1: Create migration SQL file: `src/core/migrations/027_surgical_sync_runs.sql`\n\n```sql\n-- Migration 027: Extend sync_runs for surgical sync observability\n-- Adds mode/phase tracking and surgical-specific counters.\n\nALTER TABLE sync_runs ADD COLUMN mode TEXT;\nALTER TABLE sync_runs ADD COLUMN phase TEXT;\nALTER TABLE sync_runs ADD COLUMN surgical_iids_json TEXT;\nALTER TABLE sync_runs ADD COLUMN issues_fetched INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN mrs_fetched INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN issues_ingested INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN mrs_ingested INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN skipped_stale INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN docs_regenerated INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN docs_embedded INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN warnings_count INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN cancelled_at INTEGER;\n\nCREATE INDEX IF NOT EXISTS idx_sync_runs_mode_started\n ON sync_runs(mode, started_at DESC);\nCREATE INDEX IF NOT EXISTS idx_sync_runs_status_phase_started\n ON sync_runs(status, phase, started_at DESC);\n```\n\n**Column semantics:**\n- `mode`: \"standard\" or \"surgical\" (NULL for pre-migration rows)\n- `phase`: preflight, ingest, dependents, docs, embed, done, failed, cancelled\n- `surgical_iids_json`: JSON like `{\"issues\":[7,8],\"mrs\":[101]}`\n- Counter columns: integers with DEFAULT 0 for backward compat\n- `cancelled_at`: ms-epoch timestamp, NULL unless cancelled\n\n### Step 2: Register in MIGRATIONS array (src/core/db.rs)\n\nAdd to the `MIGRATIONS` array (currently 26 entries ending with `026_scoring_indexes.sql`):\n\n```rust\n(27, include_str!(\"migrations/027_surgical_sync_runs.sql\")),\n```\n\n## Acceptance Criteria\n- [ ] File `src/core/migrations/027_surgical_sync_runs.sql` exists with all ALTER TABLE and CREATE INDEX statements\n- [ ] Migration 027 is registered in MIGRATIONS array in `src/core/db.rs`\n- [ ] `LATEST_SCHEMA_VERSION` evaluates to 27\n- [ ] Migration runs successfully on fresh databases (in-memory test)\n- [ ] Pre-existing sync_runs rows are unaffected (NULL mode/phase, 0 counters)\n- [ ] New columns accept expected values via INSERT and SELECT round-trip\n- [ ] NULL defaults work for mode, phase, surgical_iids_json, cancelled_at\n- [ ] DEFAULT 0 works for all counter columns\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo test` passes (all migration tests use in-memory DB)\n\n## Files\n- CREATE: src/core/migrations/027_surgical_sync_runs.sql\n- MODIFY: src/core/db.rs (add entry to MIGRATIONS array)\n\n## TDD Anchor\nRED: Write tests in `src/core/sync_run_tests.rs` (which is already `#[path]`-included from `sync_run.rs`):\n\n```rust\n#[test]\nfn sync_run_surgical_columns_exist() {\n let conn = setup_test_db();\n conn.execute(\n \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command, mode, phase, surgical_iids_json)\n VALUES (1000, 1000, 'running', 'sync', 'surgical', 'preflight', '{\\\"issues\\\":[7],\\\"mrs\\\":[]}')\",\n [],\n ).unwrap();\n let (mode, phase, iids_json): (String, String, String) = conn.query_row(\n \"SELECT mode, phase, surgical_iids_json FROM sync_runs WHERE mode = 'surgical'\",\n [],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n ).unwrap();\n assert_eq!(mode, \"surgical\");\n assert_eq!(phase, \"preflight\");\n assert!(iids_json.contains(\"7\"));\n}\n\n#[test]\nfn sync_run_counter_defaults_are_zero() {\n let conn = setup_test_db();\n conn.execute(\n \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command)\n VALUES (2000, 2000, 'running', 'sync')\",\n [],\n ).unwrap();\n let row_id = conn.last_insert_rowid();\n let (issues_fetched, mrs_fetched, docs_regenerated, warnings_count): (i64, i64, i64, i64) = conn.query_row(\n \"SELECT issues_fetched, mrs_fetched, docs_regenerated, warnings_count FROM sync_runs WHERE id = ?1\",\n [row_id],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),\n ).unwrap();\n assert_eq!(issues_fetched, 0);\n assert_eq!(mrs_fetched, 0);\n assert_eq!(docs_regenerated, 0);\n assert_eq!(warnings_count, 0);\n}\n\n#[test]\nfn sync_run_nullable_columns_default_to_null() {\n let conn = setup_test_db();\n conn.execute(\n \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command)\n VALUES (3000, 3000, 'running', 'sync')\",\n [],\n ).unwrap();\n let row_id = conn.last_insert_rowid();\n let (mode, phase, cancelled_at): (Option, Option, Option) = conn.query_row(\n \"SELECT mode, phase, cancelled_at FROM sync_runs WHERE id = ?1\",\n [row_id],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n ).unwrap();\n assert!(mode.is_none());\n assert!(phase.is_none());\n assert!(cancelled_at.is_none());\n}\n\n#[test]\nfn sync_run_counter_round_trip() {\n let conn = setup_test_db();\n conn.execute(\n \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command, mode, issues_fetched, mrs_ingested, docs_embedded)\n VALUES (4000, 4000, 'succeeded', 'sync', 'surgical', 3, 2, 5)\",\n [],\n ).unwrap();\n let row_id = conn.last_insert_rowid();\n let (issues_fetched, mrs_ingested, docs_embedded): (i64, i64, i64) = conn.query_row(\n \"SELECT issues_fetched, mrs_ingested, docs_embedded FROM sync_runs WHERE id = ?1\",\n [row_id],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n ).unwrap();\n assert_eq!(issues_fetched, 3);\n assert_eq!(mrs_ingested, 2);\n assert_eq!(docs_embedded, 5);\n}\n```\n\nGREEN: Create the SQL file and register the migration.\nVERIFY: `cargo test sync_run_surgical && cargo test sync_run_counter && cargo test sync_run_nullable`\n\n## Edge Cases\n- SQLite ALTER TABLE ADD COLUMN requires DEFAULT for NOT NULL columns. All counter columns use `DEFAULT 0`.\n- mode/phase/surgical_iids_json/cancelled_at are nullable TEXT/INTEGER — no DEFAULT needed.\n- Pre-migration rows get NULL for new nullable columns and 0 for counter columns — backward compatible.\n- The indexes (`idx_sync_runs_mode_started`, `idx_sync_runs_status_phase_started`) use `IF NOT EXISTS` for idempotency.\n\n## Dependency Context\nThis is a leaf/foundation bead with no upstream dependencies. Downstream bead bd-arka (SyncRunRecorder extensions) depends on these columns existing to write surgical mode lifecycle data.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-17T19:13:19.914672Z","created_by":"tayloreernisse","updated_at":"2026-02-17T20:03:28.195017Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-tiux","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-tiux","depends_on_id":"bd-arka","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} +{"id":"bd-tiux","title":"Add sync_runs migration 027 for surgical mode columns","description":"## Background\nThe `sync_runs` table (created in migration 001, enriched in 014) tracks sync run lifecycle for observability and crash recovery. Surgical sync needs additional columns to track its distinct mode, phase progression, IID targeting, and per-stage counters. This is a schema-only change — no Rust struct changes beyond registering the migration SQL file.\n\nThe migration system uses a `MIGRATIONS` array in `src/core/db.rs`. Each entry is a `(version, sql_file_name)` tuple. SQL files live in `src/core/migrations/`. The current latest migration is 026 (`026_scoring_indexes.sql`), so this will be migration 027. `LATEST_SCHEMA_VERSION` is computed as `MIGRATIONS.len() as i32` and automatically becomes 27.\n\n## Approach\n\n### Step 1: Create migration SQL file: `src/core/migrations/027_surgical_sync_runs.sql`\n\n```sql\n-- Migration 027: Extend sync_runs for surgical sync observability\n-- Adds mode/phase tracking and surgical-specific counters.\n\nALTER TABLE sync_runs ADD COLUMN mode TEXT;\nALTER TABLE sync_runs ADD COLUMN phase TEXT;\nALTER TABLE sync_runs ADD COLUMN surgical_iids_json TEXT;\nALTER TABLE sync_runs ADD COLUMN issues_fetched INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN mrs_fetched INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN issues_ingested INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN mrs_ingested INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN skipped_stale INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN docs_regenerated INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN docs_embedded INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN warnings_count INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN cancelled_at INTEGER;\n\nCREATE INDEX IF NOT EXISTS idx_sync_runs_mode_started\n ON sync_runs(mode, started_at DESC);\nCREATE INDEX IF NOT EXISTS idx_sync_runs_status_phase_started\n ON sync_runs(status, phase, started_at DESC);\n```\n\n**Column semantics:**\n- `mode`: \"standard\" or \"surgical\" (NULL for pre-migration rows)\n- `phase`: preflight, ingest, dependents, docs, embed, done, failed, cancelled\n- `surgical_iids_json`: JSON like `{\"issues\":[7,8],\"mrs\":[101]}`\n- Counter columns: integers with DEFAULT 0 for backward compat\n- `cancelled_at`: ms-epoch timestamp, NULL unless cancelled\n\n### Step 2: Register in MIGRATIONS array (src/core/db.rs)\n\nAdd to the `MIGRATIONS` array (currently 26 entries ending with `026_scoring_indexes.sql`):\n\n```rust\n(27, include_str!(\"migrations/027_surgical_sync_runs.sql\")),\n```\n\n## Acceptance Criteria\n- [ ] File `src/core/migrations/027_surgical_sync_runs.sql` exists with all ALTER TABLE and CREATE INDEX statements\n- [ ] Migration 027 is registered in MIGRATIONS array in `src/core/db.rs`\n- [ ] `LATEST_SCHEMA_VERSION` evaluates to 27\n- [ ] Migration runs successfully on fresh databases (in-memory test)\n- [ ] Pre-existing sync_runs rows are unaffected (NULL mode/phase, 0 counters)\n- [ ] New columns accept expected values via INSERT and SELECT round-trip\n- [ ] NULL defaults work for mode, phase, surgical_iids_json, cancelled_at\n- [ ] DEFAULT 0 works for all counter columns\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo test` passes (all migration tests use in-memory DB)\n\n## Files\n- CREATE: src/core/migrations/027_surgical_sync_runs.sql\n- MODIFY: src/core/db.rs (add entry to MIGRATIONS array)\n\n## TDD Anchor\nRED: Write tests in `src/core/sync_run_tests.rs` (which is already `#[path]`-included from `sync_run.rs`):\n\n```rust\n#[test]\nfn sync_run_surgical_columns_exist() {\n let conn = setup_test_db();\n conn.execute(\n \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command, mode, phase, surgical_iids_json)\n VALUES (1000, 1000, 'running', 'sync', 'surgical', 'preflight', '{\\\"issues\\\":[7],\\\"mrs\\\":[]}')\",\n [],\n ).unwrap();\n let (mode, phase, iids_json): (String, String, String) = conn.query_row(\n \"SELECT mode, phase, surgical_iids_json FROM sync_runs WHERE mode = 'surgical'\",\n [],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n ).unwrap();\n assert_eq!(mode, \"surgical\");\n assert_eq!(phase, \"preflight\");\n assert!(iids_json.contains(\"7\"));\n}\n\n#[test]\nfn sync_run_counter_defaults_are_zero() {\n let conn = setup_test_db();\n conn.execute(\n \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command)\n VALUES (2000, 2000, 'running', 'sync')\",\n [],\n ).unwrap();\n let row_id = conn.last_insert_rowid();\n let (issues_fetched, mrs_fetched, docs_regenerated, warnings_count): (i64, i64, i64, i64) = conn.query_row(\n \"SELECT issues_fetched, mrs_fetched, docs_regenerated, warnings_count FROM sync_runs WHERE id = ?1\",\n [row_id],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),\n ).unwrap();\n assert_eq!(issues_fetched, 0);\n assert_eq!(mrs_fetched, 0);\n assert_eq!(docs_regenerated, 0);\n assert_eq!(warnings_count, 0);\n}\n\n#[test]\nfn sync_run_nullable_columns_default_to_null() {\n let conn = setup_test_db();\n conn.execute(\n \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command)\n VALUES (3000, 3000, 'running', 'sync')\",\n [],\n ).unwrap();\n let row_id = conn.last_insert_rowid();\n let (mode, phase, cancelled_at): (Option, Option, Option) = conn.query_row(\n \"SELECT mode, phase, cancelled_at FROM sync_runs WHERE id = ?1\",\n [row_id],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n ).unwrap();\n assert!(mode.is_none());\n assert!(phase.is_none());\n assert!(cancelled_at.is_none());\n}\n\n#[test]\nfn sync_run_counter_round_trip() {\n let conn = setup_test_db();\n conn.execute(\n \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command, mode, issues_fetched, mrs_ingested, docs_embedded)\n VALUES (4000, 4000, 'succeeded', 'sync', 'surgical', 3, 2, 5)\",\n [],\n ).unwrap();\n let row_id = conn.last_insert_rowid();\n let (issues_fetched, mrs_ingested, docs_embedded): (i64, i64, i64) = conn.query_row(\n \"SELECT issues_fetched, mrs_ingested, docs_embedded FROM sync_runs WHERE id = ?1\",\n [row_id],\n |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n ).unwrap();\n assert_eq!(issues_fetched, 3);\n assert_eq!(mrs_ingested, 2);\n assert_eq!(docs_embedded, 5);\n}\n```\n\nGREEN: Create the SQL file and register the migration.\nVERIFY: `cargo test sync_run_surgical && cargo test sync_run_counter && cargo test sync_run_nullable`\n\n## Edge Cases\n- SQLite ALTER TABLE ADD COLUMN requires DEFAULT for NOT NULL columns. All counter columns use `DEFAULT 0`.\n- mode/phase/surgical_iids_json/cancelled_at are nullable TEXT/INTEGER — no DEFAULT needed.\n- Pre-migration rows get NULL for new nullable columns and 0 for counter columns — backward compatible.\n- The indexes (`idx_sync_runs_mode_started`, `idx_sync_runs_status_phase_started`) use `IF NOT EXISTS` for idempotency.\n\n## Dependency Context\nThis is a leaf/foundation bead with no upstream dependencies. Downstream bead bd-arka (SyncRunRecorder extensions) depends on these columns existing to write surgical mode lifecycle data.","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-17T19:13:19.914672Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:50:50.167773Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-tiux","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"},{"issue_id":"bd-tiux","depends_on_id":"bd-arka","type":"blocks","created_at":"2026-02-18T17:42:00Z","created_by":"import"}]} {"id":"bd-u7se","title":"Implement Who screen (5 modes: expert/workload/reviews/active/overlap)","description":"## Background\nThe Who screen is the people explorer, showing contributor expertise and workload across 5 modes. Each mode renders differently: Expert shows file-path expertise scores, Workload shows issue/MR assignment counts, Reviews shows review activity, Active shows recent contributors, Overlap shows shared file knowledge.\n\nOn master, the who command was refactored from a single who.rs into src/cli/commands/who/ module with types.rs, expert.rs, workload.rs, reviews.rs, active.rs, overlap.rs. Types are cleanly separated in types.rs. Query functions are currently pub(super) — bd-1f5b promotes them to pub and moves types to core.\n\n## Data Shapes (from src/cli/commands/who/types.rs on master)\n\nResult types are per-mode:\n- WhoResult enum: Expert(ExpertResult), Workload(WorkloadResult), Reviews(ReviewsResult), Active(ActiveResult), Overlap(OverlapResult)\n- ExpertResult: path_query, path_match, experts Vec, truncated — Expert has username, score, components, mr_refs, details\n- WorkloadResult: username, assigned_issues, authored_mrs, reviewing_mrs, unresolved_discussions (each with truncated flag)\n- ReviewsResult: username, total_diffnotes, categorized_count, mrs_reviewed, categories Vec\n- ActiveResult: discussions Vec, total_unresolved_in_window, truncated\n- OverlapResult: path_query, path_match, users Vec, truncated\n\nAfter bd-1f5b, these live in src/core/who_types.rs.\n\n## Query Function Signatures (after bd-1f5b promotes visibility)\n\n```rust\n// expert.rs — path-based file expertise\npub fn query_expert(conn, path, project_id, since_ms, as_of_ms, limit, scoring: &ScoringConfig, detail, explain_score, include_bots) -> Result\n\n// workload.rs — username-based assignment view\npub fn query_workload(conn, username, project_id, since_ms: Option, limit, include_closed: bool) -> Result\n\n// reviews.rs — username-based review activity\npub fn query_reviews(conn, username, project_id, since_ms) -> Result\n\n// active.rs — recent unresolved discussions\npub fn query_active(conn, project_id, since_ms, limit, include_closed: bool) -> Result\n\n// overlap.rs — shared file knowledge between contributors\npub fn query_overlap(conn, path, project_id, since_ms, limit) -> Result\n```\n\nNote: include_closed only affects query_workload and query_active. Expert, Reviews, and Overlap ignore it.\n\n## Approach\n\n**State** (state/who.rs):\n- WhoState: mode (WhoMode), result (Option), path (String), path_input (TextInput), username_input (TextInput), path_focused (bool), username_focused (bool), selected_index (usize), include_closed (bool), scroll_offset (u16)\n- WhoMode enum: Expert, Workload, Reviews, Active, Overlap\n- Expert and Overlap modes need a path input. Workload and Reviews need a username input. Active needs neither.\n\n**Action** (action.rs):\n- fetch_who_expert(conn, path, project_id, since_ms, limit, scoring) -> Result\n- fetch_who_workload(conn, username, project_id, since_ms, limit, include_closed) -> Result\n- fetch_who_reviews(conn, username, project_id, since_ms) -> Result\n- fetch_who_active(conn, project_id, since_ms, limit, include_closed) -> Result\n- fetch_who_overlap(conn, path, project_id, since_ms, limit) -> Result\nEach wraps the corresponding query_* function from who module.\n\n**View** (view/who.rs):\n- Mode tabs at top: E(xpert) | W(orkload) | R(eviews) | A(ctive) | O(verlap)\n- Input area adapts to mode: path input for Expert/Overlap, username input for Workload/Reviews, hidden for Active\n- Expert: sorted table of authors by expertise score + bar chart\n- Workload: sections for assigned issues, authored MRs, reviewing MRs, unresolved discussions\n- Reviews: table of review categories with counts and percentages\n- Active: time-sorted list of recent unresolved discussions with participants\n- Overlap: table of users with author/review touch counts\n- Keyboard: 1-5 or Tab to switch modes, j/k scroll, / focus input, c toggle include-closed, q back\n- Status bar indicator shows [closed: on/off] when include_closed is toggled\n- Truncation indicators: when result.truncated is true, show \"showing N of more\" footer\n\n## Acceptance Criteria\n- [ ] 5 modes switchable via Tab or number keys\n- [ ] Expert mode: path input filters by file path, shows expertise scores in table with bar chart\n- [ ] Workload mode: username input, shows 4 sections (assigned issues, authored MRs, reviewing MRs, unresolved discussions)\n- [ ] Reviews mode: username input, shows review category breakdown table\n- [ ] Active mode: no input needed, shows recent unresolved discussions sorted by last_note_at\n- [ ] Overlap mode: path input, shows table of users with touch counts\n- [ ] Toggle for include-closed (c key) with visual indicator — re-fetches only Workload and Active modes\n- [ ] Truncation footer when results exceed limit\n- [ ] Enter on a person in Expert/Overlap navigates to Workload for that username\n- [ ] Enter on an entity in Workload/Active navigates to IssueDetail or MrDetail\n\n## Files\n- MODIFY: crates/lore-tui/src/state/who.rs (expand from current 12-line stub)\n- MODIFY: crates/lore-tui/src/state/mod.rs (update WhoState import, add to has_text_focus/blur_text_focus)\n- MODIFY: crates/lore-tui/src/message.rs (replace placeholder WhoResult with import from core, add WhoMode enum, add Msg::WhoModeChanged, Msg::WhoIncludeClosedToggled)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_who_* functions)\n- CREATE: crates/lore-tui/src/view/who.rs\n- MODIFY: crates/lore-tui/src/view/mod.rs (add who view dispatch)\n\n## TDD Anchor\nRED: Write test_fetch_who_expert_returns_result that opens in-memory DB, inserts test MR + file changes + notes, calls fetch_who_expert(\"src/\"), asserts ExpertResult with one expert.\nGREEN: Implement fetch_who_expert calling query_expert from who module.\nVERIFY: cargo test -p lore-tui who -- --nocapture\n\nAdditional tests:\n- test_who_mode_switching: cycle through 5 modes, assert input field visibility changes\n- test_include_closed_only_affects_workload_active: toggle include_closed, verify Expert/Reviews/Overlap dont re-fetch\n- test_who_empty_result: mode with no data shows empty state message\n- test_who_truncation_indicator: result with truncated=true shows footer\n\n## Edge Cases\n- Empty results for any mode: show \"No data\" message with mode-specific hint\n- Expert mode with no diff notes: explain that expert data requires diff notes to be synced\n- Very long file paths: truncate from left (show ...path/to/file.rs)\n- include_closed toggle re-fetches immediately for Workload/Active, no-op for other modes\n- Workload unresolved_discussions may reference closed entities — include_closed=true shows them\n- ScoringConfig accessed from Config (available to TUI via db.rs module)\n\n## Dependency Context\n- bd-1f5b (blocks): Promotes query_expert, query_workload, query_reviews, query_active, query_overlap to pub and moves types to src/core/who_types.rs. Without this, TUI cannot call who queries.\n- Current WhoState stub (12 lines) in state/who.rs references message::WhoResult placeholder — must be replaced with core types.\n- AppState.has_text_focus() in state/mod.rs:194-198 must be updated to include who path_focused and username_focused.\n- AppState.blur_text_focus() in state/mod.rs:202-206 must be updated similarly.\n- Navigation from Expert/Overlap rows: Enter on a username should push Screen::Who with mode=Workload pre-filled — requires passing username to WhoState.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:22.734056Z","created_by":"tayloreernisse","updated_at":"2026-02-19T03:34:44.779093Z","closed_at":"2026-02-19T03:34:44.778985Z","close_reason":"Who screen complete: state (17 tests), action (5 bridge funcs, 5 tests), view (5 modes, 8 tests), wiring done. All quality gates pass.","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-u7se","depends_on_id":"bd-29qw","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-v6i","title":"[CP1] gi ingest --type=issues command","description":"## Background\n\nThe `gi ingest --type=issues` command is the main entry point for issue ingestion. It acquires a single-flight lock, calls the orchestrator for each configured project, and outputs progress/summary to the user.\n\n## Approach\n\n### Module: src/cli/commands/ingest.rs\n\n### Clap Definition\n\n```rust\n#[derive(Args)]\npub struct IngestArgs {\n /// Resource type to ingest\n #[arg(long, value_parser = [\"issues\", \"merge_requests\"])]\n pub r#type: String,\n\n /// Filter to single project\n #[arg(long)]\n pub project: Option,\n\n /// Override stale sync lock\n #[arg(long)]\n pub force: bool,\n}\n```\n\n### Handler Function\n\n```rust\npub async fn handle_ingest(args: IngestArgs, config: &Config) -> Result<()>\n```\n\n### Logic\n\n1. **Acquire single-flight lock**: `acquire_sync_lock(conn, args.force)?`\n2. **Get projects to sync**:\n - If `args.project` specified, filter to that one\n - Otherwise, get all configured projects from DB\n3. **For each project**:\n - Print \"Ingesting issues for {project_path}...\"\n - Call `ingest_project_issues(conn, client, config, project_id, gitlab_project_id)`\n - Print \"{N} issues fetched, {M} new labels\"\n4. **Print discussion sync summary**:\n - \"Fetching discussions ({N} issues with updates)...\"\n - \"{N} discussions, {M} notes (excluding {K} system notes)\"\n - \"Skipped discussion sync for {N} unchanged issues.\"\n5. **Release lock**: Lock auto-released when handler returns\n\n### Output Format (matches PRD)\n\n```\nIngesting issues...\n\n group/project-one: 1,234 issues fetched, 45 new labels\n\nFetching discussions (312 issues with updates)...\n\n group/project-one: 312 issues → 1,234 discussions, 5,678 notes\n\nTotal: 1,234 issues, 1,234 discussions, 5,678 notes (excluding 1,234 system notes)\nSkipped discussion sync for 922 unchanged issues.\n```\n\n## Acceptance Criteria\n\n- [ ] Clap args parse --type, --project, --force correctly\n- [ ] Single-flight lock acquired before sync starts\n- [ ] Lock error message is clear if concurrent run attempted\n- [ ] Progress output shows per-project counts\n- [ ] Summary includes unchanged issues skipped count\n- [ ] --force flag allows overriding stale lock\n\n## Files\n\n- src/cli/commands/mod.rs (add `pub mod ingest;`)\n- src/cli/commands/ingest.rs (create)\n- src/cli/mod.rs (add Ingest variant to Commands enum)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/cli_ingest_tests.rs\n#[tokio::test] async fn ingest_issues_acquires_lock()\n#[tokio::test] async fn ingest_issues_fails_on_concurrent_run()\n#[tokio::test] async fn ingest_issues_respects_project_filter()\n#[tokio::test] async fn ingest_issues_force_overrides_stale_lock()\n```\n\nGREEN: Implement handler with lock and orchestrator calls\n\nVERIFY: `cargo test cli_ingest`\n\n## Edge Cases\n\n- No projects configured - return early with helpful message\n- Project filter matches nothing - error with \"project not found\"\n- Lock already held - clear error \"Sync already in progress\"\n- Ctrl-C during sync - lock should be released (via Drop or SIGINT handler)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.312565Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:56:44.090142Z","closed_at":"2026-01-25T22:56:44.090086Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-v6i","depends_on_id":"bd-ozy","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-v6tc","title":"Description","description":"This is a test","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:52:04.745618Z","updated_at":"2026-02-12T16:52:10.755235Z","closed_at":"2026-02-12T16:52:10.755188Z","close_reason":"test artifacts","compaction_level":0,"original_size":0} @@ -325,7 +325,7 @@ {"id":"bd-wnuo","title":"Implement performance benchmark fixtures (S/M/L tiers)","description":"## Background\nTiered performance fixtures validate latency at three data scales. S and M tiers are CI-enforced gates; L tier is advisory. Fixtures are synthetic SQLite databases with realistic data distributions.\n\n## Approach\nFixture generator (benches/ or tests/fixtures/):\n- S-tier: 10k issues, 5k MRs, 50k notes, 10k docs\n- M-tier: 100k issues, 50k MRs, 500k notes, 50k docs\n- L-tier: 250k issues, 100k MRs, 1M notes, 100k docs\n- Realistic distributions: state (60% closed, 30% opened, 10% other), authors from pool of 50 names, labels from pool of 20, dates spanning 2 years\n\nBenchmarks:\n- p95 first-paint latency: Dashboard load, Issue List load, MR List load\n- p95 keyset pagination: next page fetch\n- p95 search latency: lexical and hybrid modes\n- Memory ceiling: RSS after full dashboard + list load\n- SLO assertions per tier (see Phase 0 criteria)\n\nRequired indexes must be present in fixture DBs:\n- idx_issues_list_default, idx_mrs_list_default, idx_discussions_entity, idx_notes_discussion\n\n## Acceptance Criteria\n- [ ] S-tier fixture generated with correct counts\n- [ ] M-tier fixture generated with correct counts\n- [ ] L-tier fixture generated (on-demand, not CI)\n- [ ] p95 first-paint < 50ms (S), < 75ms (M), < 150ms (L)\n- [ ] p95 keyset pagination < 50ms (S), < 75ms (M), < 100ms (L)\n- [ ] p95 search latency < 100ms (S), < 200ms (M), < 400ms (L)\n- [ ] Memory < 150MB RSS (S), < 250MB RSS (M)\n- [ ] All required indexes present in fixtures\n- [ ] EXPLAIN QUERY PLAN shows index usage for top 10 queries\n\n## Files\n- CREATE: crates/lore-tui/benches/perf_benchmarks.rs\n- CREATE: crates/lore-tui/tests/fixtures/generate_fixtures.rs\n\n## TDD Anchor\nRED: Write benchmark_dashboard_load_s_tier that generates S-tier fixture, measures Dashboard load time, asserts p95 < 50ms.\nGREEN: Implement fetch_dashboard with efficient queries.\nVERIFY: cargo bench --manifest-path crates/lore-tui/Cargo.toml\n\n## Edge Cases\n- Fixture generation must be deterministic (seeded RNG) for reproducible benchmarks\n- CI machines may be slower — use generous multipliers or relative thresholds\n- S-tier fits in memory; M-tier requires WAL mode for concurrent access\n- Benchmark warmup: discard first 5 iterations\n\n## Dependency Context\nUses all action.rs query functions from Phase 2/3 tasks.\nUses DbManager from \"Implement DbManager\" task.\nUses required index migrations from the main lore crate.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:05:12.867291Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:38.463811Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-wnuo","depends_on_id":"bd-1b6k","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-wnuo","depends_on_id":"bd-3eis","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-wrw1","title":"Implement CLI/TUI parity tests (counts, lists, detail, search, sanitization)","description":"## Background\nParity tests ensure the TUI and CLI show the same data. Both interfaces query the same SQLite database, but through different code paths (TUI action functions vs CLI command handlers). Drift can occur when query functions are duplicated or modified independently. These tests catch drift by running both code paths against the same in-memory DB and comparing results.\n\n## Approach\n\n### Test Strategy: Library-Level (Same Process)\nTests run in the same process with a shared in-memory SQLite DB. No binary execution, no JSON parsing, no process spawning. Both TUI action functions and CLI query functions are called as library code.\n\nSetup pattern:\n```rust\nuse lore::core::db::{create_connection, run_migrations};\nuse std::path::Path;\n\nfn setup_parity_db() -> rusqlite::Connection {\n let conn = create_connection(Path::new(\":memory:\")).unwrap();\n run_migrations(&conn).unwrap();\n insert_fixture_data(&conn); // shared fixture with known counts\n conn\n}\n```\n\n### Fixture Data\nCreate a deterministic fixture with known quantities:\n- 1 project (gitlab_project_id=1, path_with_namespace=\"group/repo\", web_url=\"https://gitlab.example.com/group/repo\")\n- 15 issues (5 opened, 5 closed, 5 with various states)\n- 10 merge_requests (3 opened, 3 merged, 2 closed, 2 draft)\n- 30 discussions (20 for issues, 10 for MRs)\n- 60 notes (2 per discussion)\n- Insert via direct SQL (same pattern as existing tests in src/core/db.rs)\n\n### Parity Checks\n\n**Dashboard Count Parity:**\n- TUI: call the dashboard fetch function that returns entity counts\n- CLI: call the same count query functions used by `lore --robot count`\n- Assert: issue_count, mr_count, discussion_count, note_count all match\n\n**Issue List Parity:**\n- TUI: call issue list action with default filter (state=all, limit=50, sort=updated_at DESC)\n- CLI: call the issue list query used by `lore --robot issues`\n- Assert: same IIDs in same order, same state values for each\n\n**MR List Parity:**\n- TUI: call MR list action with default filter\n- CLI: call the MR list query used by `lore --robot mrs`\n- Assert: same IIDs in same order, same state values, same draft flags\n\n**Issue Detail Parity:**\n- TUI: call issue detail fetch for a specific IID\n- CLI: call the issue detail query used by `lore --robot issues `\n- Assert: same metadata fields (title, state, author, labels, created_at, updated_at), same discussion count\n\n**Search Parity:**\n- TUI: call search action with a known query term\n- CLI: call the search function used by `lore --robot search`\n- Assert: same document IDs returned in same rank order\n\n**Sanitization Parity:**\n- Insert an issue with ANSI escape sequences in the title: \"Normal \\x1b[31mRED\\x1b[0m text\"\n- TUI: fetch and sanitize via terminal safety module\n- CLI: fetch and render via robot mode (which strips ANSI)\n- Assert: both produce clean output without raw escape sequences\n\n## Acceptance Criteria\n- [ ] Dashboard counts: TUI == CLI for issues, MRs, discussions, notes on shared fixture\n- [ ] Issue list: TUI returns same IIDs in same order as CLI query function\n- [ ] MR list: TUI returns same IIDs in same order as CLI query function\n- [ ] Issue detail: TUI metadata matches CLI for title, state, author, discussion count\n- [ ] Search results: same document IDs in same rank order\n- [ ] Sanitization: both strip ANSI escape sequences from issue titles\n- [ ] All tests use in-memory DB (no file I/O, no binary spawning)\n- [ ] Tests are deterministic (fixed fixture, no wall clock dependency)\n\n## Files\n- CREATE: crates/lore-tui/tests/parity_tests.rs\n\n## TDD Anchor\nRED: Write `test_dashboard_count_parity` that creates shared fixture DB, calls both TUI dashboard fetch and CLI count query functions, asserts all counts equal.\nGREEN: Ensure TUI query functions exist and match CLI query logic.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml parity\n\nAdditional tests:\n- test_issue_list_parity\n- test_mr_list_parity\n- test_issue_detail_parity\n- test_search_parity\n- test_sanitization_parity\n\n## Edge Cases\n- CLI and TUI may use different default sort orders — normalize to same ORDER BY in test setup\n- CLI list commands default to limit=50, TUI may default to page size — test with explicit limit\n- Fixture must include edge cases: NULL labels, empty descriptions, issues with work item status set\n- Schema version must match between both code paths (same migration version)\n- FTS index must be populated for search parity (call generate-docs equivalent on fixture)\n\n## Dependency Context\n- Uses TUI action functions from Phase 2/3 screen beads (must exist as library code)\n- Uses CLI query functions from src/cli/ (already exist as `lore` library exports)\n- Uses lore::core::db for shared DB setup\n- Uses terminal safety module (bd-3ir1) for sanitization comparison\n- Depends on bd-14hv (soak tests) being complete per phase ordering","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:05:51.620596Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:38.629958Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-wrw1","depends_on_id":"bd-14hv","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-wrw1","depends_on_id":"bd-2o49","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-wzqi","title":"Implement Command Palette (state + view)","description":"## Background\nThe Command Palette is a modal overlay (Ctrl+P) that provides fuzzy-match access to all commands. It uses FrankenTUI's built-in CommandPalette widget and is populated from the CommandRegistry.\n\n## Approach\nState (state/command_palette.rs):\n- CommandPaletteState: wraps ftui CommandPalette widget state\n- input (String), filtered_commands (Vec), selected_index (usize), visible (bool)\n\nView (view/command_palette.rs):\n- Modal overlay centered on screen (60% width, 50% height)\n- Text input at top for fuzzy search\n- Scrollable list of matching commands with keybinding hints\n- Enter executes selected command, Esc closes palette\n- Fuzzy matching: subsequence match on command label and help text\n\nIntegration:\n- Ctrl+P from any screen opens palette (handled in interpret_key stage 2)\n- execute_palette_action() in app.rs converts selected command to Msg\n\n## Acceptance Criteria\n- [ ] Ctrl+P opens palette from any screen in Normal mode\n- [ ] Fuzzy matching filters commands as user types\n- [ ] Commands show label + keybinding + help text\n- [ ] Enter executes selected command\n- [ ] Esc closes palette without action\n- [ ] Palette populated from CommandRegistry (single source of truth)\n- [ ] Modal renders on top of current screen content\n\n## Files\n- MODIFY: crates/lore-tui/src/state/command_palette.rs (expand from stub)\n- CREATE: crates/lore-tui/src/view/command_palette.rs\n\n## TDD Anchor\nRED: Write test_palette_fuzzy_match that creates registry with 5 commands, filters with \"iss\", asserts Issue-related commands match.\nGREEN: Implement fuzzy matching on command labels.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_palette_fuzzy\n\n## Edge Cases\n- Empty search shows all commands\n- Very long command labels: truncate with ellipsis\n- Command not available on current screen: show but gray out\n- Palette should not steal focus from text inputs — only opens in Normal mode\n\n## Dependency Context\nUses CommandRegistry from \"Implement CommandRegistry\" task.\nUses ftui CommandPalette widget from FrankenTUI.\nUses InputMode::Palette from \"Implement core types\" task.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:37.250065Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:12:33.957535Z","closed_at":"2026-02-18T21:12:33.957486Z","close_reason":"Command palette implemented: fuzzy matching state (13 tests), modal overlay view (6 tests), full keyboard handling (Esc/Enter/Up/Down/Backspace/typing), wired into view/mod.rs overlay layer","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-wzqi","depends_on_id":"bd-35g5","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} -{"id":"bd-x8oq","title":"Write surgical_tests.rs with TDD test suite","description":"## Background\n\nThe surgical sync module (`src/ingestion/surgical.rs` from bd-3sez) needs a comprehensive test suite. Tests use in-memory SQLite (no real GitLab or Ollama) and wiremock for HTTP mocks. The test file lives at `src/ingestion/surgical_tests.rs` and is included via `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;` in surgical.rs.\n\nKey testing constraints:\n- In-memory DB pattern: `create_connection(Path::new(\":memory:\"))` + `run_migrations(&conn)`\n- Test project insert: `INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)` (no `name`/`last_seen_at` columns)\n- `GitLabIssue` required fields: `id`, `iid`, `project_id`, `title`, `state`, `created_at`, `updated_at`, `author`, `web_url`\n- `GitLabMergeRequest` adds: `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`\n- `updated_at` is `String` (ISO 8601) in GitLab types, e.g. `\"2026-02-17T12:00:00.000+00:00\"`\n- `SourceType` enum variants: `Issue`, `MergeRequest`, `Discussion`, `Note`\n- `dirty_sources` table: `(source_type TEXT, source_id INTEGER)` primary key\n\n## Approach\n\nCreate `src/ingestion/surgical_tests.rs` with:\n\n### Test Helpers\n- `setup_db() -> Connection` — in-memory DB with migrations + test project row\n- `make_test_issue(iid: i64, updated_at: &str) -> GitLabIssue` — minimal valid JSON fixture\n- `make_test_mr(iid: i64, updated_at: &str) -> GitLabMergeRequest` — minimal valid JSON fixture\n- `get_db_updated_at(conn, table, iid) -> Option` — helper to query DB updated_at for assertions\n- `get_dirty_keys(conn) -> Vec<(String, i64)>` — query dirty_sources for assertions\n\n### Sync Tests (13)\n1. `test_ingest_issue_by_iid_upserts_and_marks_dirty` — fresh issue ingest, verify DB row + dirty_sources entry\n2. `test_ingest_mr_by_iid_upserts_and_marks_dirty` — fresh MR ingest, verify DB row + dirty_sources entry\n3. `test_toctou_skips_stale_issue` — insert issue at T1, call ingest with payload at T1, assert skipped_stale=true and no dirty mark\n4. `test_toctou_skips_stale_mr` — same for MRs\n5. `test_toctou_allows_newer_issue` — DB has T1, payload has T2 (T2 > T1), assert upserted=true\n6. `test_toctou_allows_newer_mr` — same for MRs\n7. `test_is_stale_parses_iso8601` — unit test: `\"2026-02-17T12:00:00.000+00:00\"` parses to correct ms-epoch\n8. `test_is_stale_handles_none_db_value` — first ingest, no DB row, assert not stale\n9. `test_is_stale_with_z_suffix` — `\"2026-02-17T12:00:00Z\"` also parses correctly\n10. `test_ingest_issue_returns_dirty_source_keys` — verify `dirty_source_keys` contains `(SourceType::Issue, local_id)`\n11. `test_ingest_mr_returns_dirty_source_keys` — verify MR dirty source keys\n12. `test_ingest_issue_updates_existing` — ingest same IID twice with newer updated_at, verify update\n13. `test_ingest_mr_updates_existing` — same for MRs\n\n### Async Preflight Test (1, wiremock)\n14. `test_preflight_fetch_returns_issues_and_mrs` — wiremock GET `/projects/:id/issues?iids[]=42` returns 200 with fixture, verify PreflightResult.issues has 1 entry\n\n### Integration Stubs (4, for bd-3jqx)\n15. `test_surgical_cancellation_during_preflight` — stub: signal.cancel() before preflight, verify early return\n16. `test_surgical_timeout_during_fetch` — stub: wiremock delay exceeds timeout\n17. `test_surgical_embed_isolation` — stub: verify only surgical docs get embedded\n18. `test_surgical_payload_integrity` — stub: verify ingested data matches GitLab payload exactly\n\n## Acceptance Criteria\n\n- [ ] All 13 sync tests pass with in-memory SQLite\n- [ ] Async preflight test passes with wiremock\n- [ ] 4 integration stubs compile and are marked `#[ignore]` (implemented in bd-3jqx)\n- [ ] Test helpers produce valid GitLabIssue/GitLabMergeRequest fixtures that pass `transform_issue`/`transform_merge_request`\n- [ ] No flaky tests: deterministic timestamps, no real network calls\n- [ ] File wired into surgical.rs via `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;`\n\n## Files\n\n- `src/ingestion/surgical_tests.rs` (NEW)\n- `src/ingestion/surgical.rs` (add `#[cfg(test)]` module path — created in bd-3sez)\n\n## TDD Anchor\n\nThis bead IS the test suite. Tests are written first (TDD red phase), then bd-3sez implements the production code to make them pass (green phase). Specific test signatures:\n\n```rust\n#[test]\nfn test_ingest_issue_by_iid_upserts_and_marks_dirty() {\n let conn = setup_db();\n let issue = make_test_issue(42, \"2026-02-17T12:00:00.000+00:00\");\n let config = Config::default();\n let result = ingest_issue_by_iid(&conn, &config, /*project_id=*/1, &issue).unwrap();\n assert!(result.upserted);\n assert!(!result.skipped_stale);\n let dirty = get_dirty_keys(&conn);\n assert!(dirty.contains(&(\"issue\".to_string(), /*local_id from DB*/)));\n}\n\n#[test]\nfn test_toctou_skips_stale_issue() {\n let conn = setup_db();\n let issue = make_test_issue(42, \"2026-02-17T12:00:00.000+00:00\");\n ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();\n // Ingest same timestamp again\n let result = ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();\n assert!(result.skipped_stale);\n}\n\n#[tokio::test]\nasync fn test_preflight_fetch_returns_issues_and_mrs() {\n let mock = MockServer::start().await;\n // ... wiremock setup ...\n}\n```\n\n## Edge Cases\n\n- `make_test_issue` must produce all required fields (`id`, `iid`, `project_id`, `title`, `state`, `created_at`, `updated_at`, `author` with `username` and `id`, `web_url`) or `transform_issue` will fail\n- `make_test_mr` additionally needs `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`\n- ISO 8601 fixtures must use `+00:00` suffix (GitLab format), not `Z`\n- Integration stubs must be `#[ignore]` so they do not fail CI before bd-3jqx implements them\n- Test DB needs `run_migrations` to create all tables including `dirty_sources`, `documents`, `issues`, `merge_requests`\n\n## Dependency Context\n\n- **Blocked by bd-3sez**: Cannot compile tests until surgical.rs module exists (circular co-dependency — develop together)\n- **Blocks bd-3jqx**: Integration test stubs are implemented in that bead\n- **No other blockers**: Uses only in-memory DB and wiremock, no external dependencies","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-17T19:15:05.498388Z","created_by":"tayloreernisse","updated_at":"2026-02-17T20:02:42.840151Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"]} +{"id":"bd-x8oq","title":"Write surgical_tests.rs with TDD test suite","description":"## Background\n\nThe surgical sync module (`src/ingestion/surgical.rs` from bd-3sez) needs a comprehensive test suite. Tests use in-memory SQLite (no real GitLab or Ollama) and wiremock for HTTP mocks. The test file lives at `src/ingestion/surgical_tests.rs` and is included via `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;` in surgical.rs.\n\nKey testing constraints:\n- In-memory DB pattern: `create_connection(Path::new(\":memory:\"))` + `run_migrations(&conn)`\n- Test project insert: `INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)` (no `name`/`last_seen_at` columns)\n- `GitLabIssue` required fields: `id`, `iid`, `project_id`, `title`, `state`, `created_at`, `updated_at`, `author`, `web_url`\n- `GitLabMergeRequest` adds: `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`\n- `updated_at` is `String` (ISO 8601) in GitLab types, e.g. `\"2026-02-17T12:00:00.000+00:00\"`\n- `SourceType` enum variants: `Issue`, `MergeRequest`, `Discussion`, `Note`\n- `dirty_sources` table: `(source_type TEXT, source_id INTEGER)` primary key\n\n## Approach\n\nCreate `src/ingestion/surgical_tests.rs` with:\n\n### Test Helpers\n- `setup_db() -> Connection` — in-memory DB with migrations + test project row\n- `make_test_issue(iid: i64, updated_at: &str) -> GitLabIssue` — minimal valid JSON fixture\n- `make_test_mr(iid: i64, updated_at: &str) -> GitLabMergeRequest` — minimal valid JSON fixture\n- `get_db_updated_at(conn, table, iid) -> Option` — helper to query DB updated_at for assertions\n- `get_dirty_keys(conn) -> Vec<(String, i64)>` — query dirty_sources for assertions\n\n### Sync Tests (13)\n1. `test_ingest_issue_by_iid_upserts_and_marks_dirty` — fresh issue ingest, verify DB row + dirty_sources entry\n2. `test_ingest_mr_by_iid_upserts_and_marks_dirty` — fresh MR ingest, verify DB row + dirty_sources entry\n3. `test_toctou_skips_stale_issue` — insert issue at T1, call ingest with payload at T1, assert skipped_stale=true and no dirty mark\n4. `test_toctou_skips_stale_mr` — same for MRs\n5. `test_toctou_allows_newer_issue` — DB has T1, payload has T2 (T2 > T1), assert upserted=true\n6. `test_toctou_allows_newer_mr` — same for MRs\n7. `test_is_stale_parses_iso8601` — unit test: `\"2026-02-17T12:00:00.000+00:00\"` parses to correct ms-epoch\n8. `test_is_stale_handles_none_db_value` — first ingest, no DB row, assert not stale\n9. `test_is_stale_with_z_suffix` — `\"2026-02-17T12:00:00Z\"` also parses correctly\n10. `test_ingest_issue_returns_dirty_source_keys` — verify `dirty_source_keys` contains `(SourceType::Issue, local_id)`\n11. `test_ingest_mr_returns_dirty_source_keys` — verify MR dirty source keys\n12. `test_ingest_issue_updates_existing` — ingest same IID twice with newer updated_at, verify update\n13. `test_ingest_mr_updates_existing` — same for MRs\n\n### Async Preflight Test (1, wiremock)\n14. `test_preflight_fetch_returns_issues_and_mrs` — wiremock GET `/projects/:id/issues?iids[]=42` returns 200 with fixture, verify PreflightResult.issues has 1 entry\n\n### Integration Stubs (4, for bd-3jqx)\n15. `test_surgical_cancellation_during_preflight` — stub: signal.cancel() before preflight, verify early return\n16. `test_surgical_timeout_during_fetch` — stub: wiremock delay exceeds timeout\n17. `test_surgical_embed_isolation` — stub: verify only surgical docs get embedded\n18. `test_surgical_payload_integrity` — stub: verify ingested data matches GitLab payload exactly\n\n## Acceptance Criteria\n\n- [ ] All 13 sync tests pass with in-memory SQLite\n- [ ] Async preflight test passes with wiremock\n- [ ] 4 integration stubs compile and are marked `#[ignore]` (implemented in bd-3jqx)\n- [ ] Test helpers produce valid GitLabIssue/GitLabMergeRequest fixtures that pass `transform_issue`/`transform_merge_request`\n- [ ] No flaky tests: deterministic timestamps, no real network calls\n- [ ] File wired into surgical.rs via `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;`\n\n## Files\n\n- `src/ingestion/surgical_tests.rs` (NEW)\n- `src/ingestion/surgical.rs` (add `#[cfg(test)]` module path — created in bd-3sez)\n\n## TDD Anchor\n\nThis bead IS the test suite. Tests are written first (TDD red phase), then bd-3sez implements the production code to make them pass (green phase). Specific test signatures:\n\n```rust\n#[test]\nfn test_ingest_issue_by_iid_upserts_and_marks_dirty() {\n let conn = setup_db();\n let issue = make_test_issue(42, \"2026-02-17T12:00:00.000+00:00\");\n let config = Config::default();\n let result = ingest_issue_by_iid(&conn, &config, /*project_id=*/1, &issue).unwrap();\n assert!(result.upserted);\n assert!(!result.skipped_stale);\n let dirty = get_dirty_keys(&conn);\n assert!(dirty.contains(&(\"issue\".to_string(), /*local_id from DB*/)));\n}\n\n#[test]\nfn test_toctou_skips_stale_issue() {\n let conn = setup_db();\n let issue = make_test_issue(42, \"2026-02-17T12:00:00.000+00:00\");\n ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();\n // Ingest same timestamp again\n let result = ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();\n assert!(result.skipped_stale);\n}\n\n#[tokio::test]\nasync fn test_preflight_fetch_returns_issues_and_mrs() {\n let mock = MockServer::start().await;\n // ... wiremock setup ...\n}\n```\n\n## Edge Cases\n\n- `make_test_issue` must produce all required fields (`id`, `iid`, `project_id`, `title`, `state`, `created_at`, `updated_at`, `author` with `username` and `id`, `web_url`) or `transform_issue` will fail\n- `make_test_mr` additionally needs `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`\n- ISO 8601 fixtures must use `+00:00` suffix (GitLab format), not `Z`\n- Integration stubs must be `#[ignore]` so they do not fail CI before bd-3jqx implements them\n- Test DB needs `run_migrations` to create all tables including `dirty_sources`, `documents`, `issues`, `merge_requests`\n\n## Dependency Context\n\n- **Blocked by bd-3sez**: Cannot compile tests until surgical.rs module exists (circular co-dependency — develop together)\n- **Blocks bd-3jqx**: Integration test stubs are implemented in that bead\n- **No other blockers**: Uses only in-memory DB and wiremock, no external dependencies","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:15:05.498388Z","created_by":"tayloreernisse","updated_at":"2026-02-19T05:49:54.777227Z","closed_at":"2026-02-19T05:49:54.777077Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"]} {"id":"bd-xhz","title":"[CP1] GitLab client pagination methods","description":"## Background\n\nGitLab pagination methods enable fetching large result sets (issues, discussions) as async streams. The client uses `x-next-page` headers to determine continuation and applies cursor rewind for tuple-based incremental sync.\n\n## Approach\n\nAdd pagination methods to GitLabClient using `async-stream` crate:\n\n### Methods to Add\n\n```rust\nimpl GitLabClient {\n /// Paginate through issues for a project.\n pub fn paginate_issues(\n &self,\n gitlab_project_id: i64,\n updated_after: Option, // ms epoch cursor\n cursor_rewind_seconds: u32,\n ) -> Pin> + Send + '_>>\n\n /// Paginate through discussions for an issue.\n pub fn paginate_issue_discussions(\n &self,\n gitlab_project_id: i64,\n issue_iid: i64,\n ) -> Pin> + Send + '_>>\n\n /// Make request and return response with headers for pagination.\n async fn request_with_headers(\n &self,\n path: &str,\n params: &[(&str, String)],\n ) -> Result<(T, HeaderMap)>\n}\n```\n\n### Pagination Logic\n\n1. Start at page 1, per_page=100\n2. For issues: add scope=all, state=all, order_by=updated_at, sort=asc\n3. Apply cursor rewind: `updated_after = cursor - rewind_seconds` (clamped to 0)\n4. Yield each item from response\n5. Check `x-next-page` header for continuation\n6. Stop when header is empty/absent OR response is empty\n\n### Cursor Rewind\n\n```rust\nif let Some(ts) = updated_after {\n let rewind_ms = (cursor_rewind_seconds as i64) * 1000;\n let rewound = (ts - rewind_ms).max(0); // Clamp to avoid underflow\n // Convert to ISO 8601 for updated_after param\n}\n```\n\n## Acceptance Criteria\n\n- [ ] `paginate_issues` returns Stream of GitLabIssue\n- [ ] `paginate_issues` adds scope=all, state=all, order_by=updated_at, sort=asc\n- [ ] `paginate_issues` applies cursor rewind with max(0) clamping\n- [ ] `paginate_issue_discussions` returns Stream of GitLabDiscussion\n- [ ] Both methods follow x-next-page header until empty\n- [ ] Both methods stop on empty response (fallback)\n- [ ] `request_with_headers` returns (T, HeaderMap) tuple\n\n## Files\n\n- src/gitlab/client.rs (edit - add methods)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/pagination_tests.rs\n#[tokio::test] async fn fetches_all_pages_when_multiple_exist()\n#[tokio::test] async fn respects_per_page_parameter()\n#[tokio::test] async fn follows_x_next_page_header_until_empty()\n#[tokio::test] async fn falls_back_to_empty_page_stop_if_headers_missing()\n#[tokio::test] async fn applies_cursor_rewind_for_tuple_semantics()\n#[tokio::test] async fn clamps_negative_rewind_to_zero()\n```\n\nGREEN: Implement pagination methods with async-stream\n\nVERIFY: `cargo test pagination`\n\n## Edge Cases\n\n- cursor_updated_at near zero - rewind must not underflow (use max(0))\n- GitLab returns empty x-next-page - treat as end of pages\n- GitLab omits pagination headers entirely - use empty response as stop condition\n- DateTime conversion fails - omit updated_after and fetch all (safe fallback)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.222168Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:28:39.192876Z","closed_at":"2026-01-25T22:28:39.192815Z","close_reason":"Implemented paginate_issues and paginate_issue_discussions with async-stream, cursor rewind with max(0) clamping, x-next-page header following, 4 unit tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-xhz","depends_on_id":"bd-1np","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"},{"issue_id":"bd-xhz","depends_on_id":"bd-2ys","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} {"id":"bd-xsgw","title":"NOTE-TEST2: Another test bead","description":"type: task","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:58:53.392214Z","updated_at":"2026-02-12T16:59:02.051710Z","closed_at":"2026-02-12T16:59:02.051663Z","close_reason":"test","compaction_level":0,"original_size":0} {"id":"bd-y095","title":"Implement SyncDeltaLedger for post-sync filtered navigation","description":"## Background\n\nAfter a sync completes, the Sync Summary screen shows delta counts (+12 new issues, +3 new MRs). Pressing `i` or `m` should navigate to Issue/MR List filtered to show ONLY the entities that changed in this sync run. The SyncDeltaLedger is an in-memory data structure (not persisted to DB) that records the exact IIDs of new/updated entities during a sync run. It lives for the duration of one TUI session and is cleared when a new sync starts. If the ledger is unavailable (e.g., after app restart), the Sync Summary falls back to a timestamp-based filter using `sync_status.last_completed_at`.\n\n## Approach\n\nCreate a `sync_delta.rs` module with:\n\n1. **`SyncDeltaLedger` struct**:\n ```rust\n pub struct SyncDeltaLedger {\n issues_new: Vec, // IIDs of newly created issues\n issues_updated: Vec, // IIDs of updated (not new) issues\n mrs_new: Vec, // IIDs of newly created MRs\n mrs_updated: Vec, // IIDs of updated MRs\n discussions_new: usize, // count only (too many to track individually)\n events_new: usize, // count only\n completed_at: Option, // timestamp when sync finished (fallback anchor)\n }\n ```\n2. **Builder pattern** — `SyncDeltaLedger::new()` starts empty, populated during sync via:\n - `record_issue(iid: i64, is_new: bool)`\n - `record_mr(iid: i64, is_new: bool)`\n - `record_discussions(count: usize)`\n - `record_events(count: usize)`\n - `finalize(completed_at: i64)` — marks ledger as complete\n3. **Query methods**:\n - `new_issue_iids() -> &[i64]` — for `i` key navigation in Summary mode\n - `new_mr_iids() -> &[i64]` — for `m` key navigation\n - `all_changed_issue_iids() -> Vec` — new + updated combined\n - `all_changed_mr_iids() -> Vec` — new + updated combined\n - `is_available() -> bool` — true if finalize() was called\n - `fallback_timestamp() -> Option` — completed_at for timestamp-based fallback\n4. **`clear()`** — resets all fields when a new sync starts\n\nThe ledger is owned by `SyncState` (part of `AppState`) and populated by the sync action handler when processing `SyncResult` from `run_sync()`. The existing `SyncResult` struct (src/cli/commands/sync.rs:30) already tracks `issues_updated` and `mrs_updated` counts but not individual IIDs — the TUI sync action will need to collect IIDs from the ingest callbacks.\n\n## Acceptance Criteria\n- [ ] `SyncDeltaLedger::new()` creates an empty ledger with `is_available() == false`\n- [ ] `record_issue(42, true)` adds 42 to `issues_new`; `record_issue(43, false)` adds to `issues_updated`\n- [ ] `new_issue_iids()` returns only new IIDs, `all_changed_issue_iids()` returns new + updated\n- [ ] `finalize(ts)` sets `is_available() == true` and stores the timestamp\n- [ ] `clear()` resets everything back to empty with `is_available() == false`\n- [ ] `fallback_timestamp()` returns None before finalize, Some(ts) after\n- [ ] Ledger handles >10,000 IIDs without issues (just Vec growth)\n\n## Files\n- CREATE: crates/lore-tui/src/sync_delta.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add `pub mod sync_delta;`)\n\n## TDD Anchor\nRED: Write `test_empty_ledger_not_available` that asserts `SyncDeltaLedger::new().is_available() == false` and `new_issue_iids().is_empty()`.\nGREEN: Implement the struct with new() and is_available().\nVERIFY: cargo test -p lore-tui sync_delta\n\nAdditional tests:\n- test_record_and_query_issues\n- test_record_and_query_mrs\n- test_finalize_makes_available\n- test_clear_resets_everything\n- test_all_changed_combines_new_and_updated\n- test_fallback_timestamp\n\n## Edge Cases\n- Recording the same IID twice (e.g., issue updated twice during sync) — should deduplicate or allow duplicates? Allow duplicates (Vec, not HashSet) for simplicity; consumers can deduplicate if needed.\n- Very large syncs with >50,000 entities — Vec is fine, no cap needed.\n- Calling query methods before finalize — returns data so far (is_available=false signals incompleteness).\n\n## Dependency Context\n- Depends on bd-2x2h (Sync screen) which owns SyncState and drives the sync lifecycle. The ledger is a field of SyncState.\n- Consumed by Sync Summary mode's `i`/`m` key handlers to produce filtered Issue/MR List navigation with exact IID sets.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:38.738460Z","created_by":"tayloreernisse","updated_at":"2026-02-19T04:40:57.445633Z","closed_at":"2026-02-19T04:40:57.445577Z","close_reason":"SyncDeltaLedger implemented as part of bd-2x2h Sync screen.","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-y095","depends_on_id":"bd-2x2h","type":"blocks","created_at":"2026-02-12T19:34:59Z","created_by":"import"}]} diff --git a/.beads/last-touched b/.beads/last-touched index 28f2c86..2129598 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -bd-2nfs +bd-2ygk diff --git a/crates/lore-tui/tests/tui_user_flows.rs b/crates/lore-tui/tests/tui_user_flows.rs new file mode 100644 index 0000000..d85c658 --- /dev/null +++ b/crates/lore-tui/tests/tui_user_flows.rs @@ -0,0 +1,677 @@ +//! User flow integration tests — PRD Section 6 end-to-end journeys. +//! +//! Each test simulates a realistic user workflow through multiple screens, +//! using key events for navigation and message injection for data loading. +//! All tests use `FakeClock` and synthetic data for determinism. +//! +//! These tests complement the vertical slice tests (bd-1mju) which cover +//! a single flow in depth. These focus on breadth — 9 distinct user +//! journeys that exercise cross-screen navigation, state preservation, +//! and the command dispatch pipeline. + +use chrono::{TimeZone, Utc}; +use ftui::{Cmd, Event, KeyCode, KeyEvent, Model, Modifiers}; + +use lore_tui::app::LoreApp; +use lore_tui::clock::FakeClock; +use lore_tui::message::{ + EntityKey, InputMode, Msg, Screen, SearchResult, TimelineEvent, TimelineEventKind, +}; +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 & clock +// --------------------------------------------------------------------------- + +/// 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()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn test_app() -> LoreApp { + let mut app = LoreApp::new(); + app.clock = Box::new(frozen_clock()); + app +} + +/// Send a key event and return the Cmd. +fn send_key(app: &mut LoreApp, code: KeyCode) -> Cmd { + app.update(Msg::RawEvent(Event::Key(KeyEvent::new(code)))) +} + +/// Send a key event with modifiers. +fn send_key_mod(app: &mut LoreApp, code: KeyCode, mods: Modifiers) -> Cmd { + app.update(Msg::RawEvent(Event::Key( + KeyEvent::new(code).with_modifiers(mods), + ))) +} + +/// Send a g-prefix navigation sequence (e.g., 'g' then 'i' for issues). +fn send_go(app: &mut LoreApp, second: char) { + send_key(app, KeyCode::Char('g')); + send_key(app, KeyCode::Char(second)); +} + +// -- 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, + }, + ], + recent: vec![], + last_sync: Some(LastSyncInfo { + status: "succeeded".into(), + 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, + }, + IssueListRow { + project_path: "web/frontend".into(), + iid: 55, + title: "Dark mode toggle not persisting".into(), + state: "opened".into(), + author: "bob".into(), + labels: vec!["ui".into(), "bug".into()], + updated_at: 1_736_938_400_000, + }, + 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, + }, + ], + 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 errors.".into(), + state: "opened".into(), + author: "alice".into(), + assignees: vec!["bob".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, + 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...".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...".into(), + score: 0.82, + project_path: "infra/platform".into(), + }, + ] +} + +fn fixture_timeline_events() -> Vec { + vec![ + TimelineEvent { + timestamp_ms: 1_736_942_000_000, + entity_key: EntityKey::issue(1, 101), + event_kind: TimelineEventKind::Created, + summary: "Issue #101 created".into(), + detail: None, + actor: Some("alice".into()), + project_path: "infra/platform".into(), + }, + TimelineEvent { + timestamp_ms: 1_736_938_400_000, + entity_key: EntityKey::mr(1, 42), + event_kind: TimelineEventKind::Created, + summary: "MR !42 created".into(), + detail: None, + actor: Some("bob".into()), + 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) { + 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, key: EntityKey) { + let screen = Screen::IssueDetail(key.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) { + 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::SearchQueryChanged("retry backoff".into())); + let generation = app + .supervisor + .submit(TaskKey::LoadScreen(Screen::Search)) + .generation; + // Align state generation with supervisor generation so both guards pass. + app.state.search.generation = generation; + app.update(Msg::SearchExecuted { + generation, + results: fixture_search_results(), + }); +} + +fn load_timeline(app: &mut LoreApp) { + let generation = app + .supervisor + .submit(TaskKey::LoadScreen(Screen::Timeline)) + .generation; + // Align state generation with supervisor generation so both guards pass. + app.state.timeline.generation = generation; + app.update(Msg::TimelineLoaded { + generation, + events: fixture_timeline_events(), + }); +} + +// --------------------------------------------------------------------------- +// Flow 1: Morning Triage +// --------------------------------------------------------------------------- +// Dashboard -> gi -> Issue List (with data) -> detail (via Msg) -> Esc back +// Verifies cursor preservation and state on back-navigation. + +#[test] +fn test_flow_morning_triage() { + let mut app = test_app(); + load_dashboard(&mut app); + assert!(app.navigation.is_at(&Screen::Dashboard)); + + // Navigate to issue list via g-prefix. + send_go(&mut app, 'i'); + assert!(app.navigation.is_at(&Screen::IssueList)); + + // Inject issue list data. + load_issue_list(&mut app); + assert_eq!(app.state.issue_list.rows.len(), 3); + + // Simulate selecting the second item (cursor state). + app.state.issue_list.selected_index = 1; + + // Navigate to issue detail for the second row (iid=55). + let issue_key = EntityKey::issue(1, 55); + app.update(Msg::NavigateTo(Screen::IssueDetail(issue_key.clone()))); + load_issue_detail(&mut app, issue_key); + assert!(matches!(app.navigation.current(), Screen::IssueDetail(_))); + + // Go back via Esc. + send_key(&mut app, KeyCode::Escape); + assert!( + app.navigation.is_at(&Screen::IssueList), + "Esc should return to issue list" + ); + + // Cursor position should be preserved. + assert_eq!( + app.state.issue_list.selected_index, 1, + "Cursor should be preserved on the second row after back-navigation" + ); + + // Data should still be there. + assert_eq!(app.state.issue_list.rows.len(), 3); +} + +// --------------------------------------------------------------------------- +// Flow 2: Direct Screen Jumps (g-prefix chain) +// --------------------------------------------------------------------------- +// Issue Detail -> gt (Timeline) -> gw (Who) -> gi (Issues) -> gh (Dashboard) +// Verifies the g-prefix navigation chain works across screens. + +#[test] +fn test_flow_direct_screen_jumps() { + let mut app = test_app(); + load_dashboard(&mut app); + + // Start on issue detail. + let key = EntityKey::issue(1, 101); + app.update(Msg::NavigateTo(Screen::IssueDetail(key.clone()))); + load_issue_detail(&mut app, key); + assert!(matches!(app.navigation.current(), Screen::IssueDetail(_))); + + // Jump to Timeline. + send_go(&mut app, 't'); + assert!( + app.navigation.is_at(&Screen::Timeline), + "gt should jump to Timeline" + ); + + // Jump to Who. + send_go(&mut app, 'w'); + assert!(app.navigation.is_at(&Screen::Who), "gw should jump to Who"); + + // Jump to Issues. + send_go(&mut app, 'i'); + assert!( + app.navigation.is_at(&Screen::IssueList), + "gi should jump to Issue List" + ); + + // Jump Home (Dashboard). + send_go(&mut app, 'h'); + assert!( + app.navigation.is_at(&Screen::Dashboard), + "gh should jump to Dashboard" + ); +} + +// --------------------------------------------------------------------------- +// Flow 3: Quick Search +// --------------------------------------------------------------------------- +// Any screen -> g/ -> Search -> inject query and results -> verify results + +#[test] +fn test_flow_quick_search() { + let mut app = test_app(); + load_dashboard(&mut app); + + // Navigate to search via g-prefix. + send_go(&mut app, '/'); + assert!( + app.navigation.is_at(&Screen::Search), + "g/ should navigate to Search" + ); + + // Inject search query and results. + load_search_results(&mut app); + assert_eq!(app.state.search.results.len(), 2); + assert_eq!( + app.state.search.results[0].title, + "Add retry logic for transient failures" + ); + + // Navigate to a result via Msg (simulating Enter on first result). + let result_key = app.state.search.results[0].key.clone(); + app.update(Msg::NavigateTo(Screen::IssueDetail(result_key.clone()))); + load_issue_detail(&mut app, result_key); + assert!(matches!(app.navigation.current(), Screen::IssueDetail(_))); + + // Go back to search — results should be preserved. + send_key(&mut app, KeyCode::Escape); + assert!(app.navigation.is_at(&Screen::Search)); + assert_eq!(app.state.search.results.len(), 2); +} + +// --------------------------------------------------------------------------- +// Flow 4: Sync and Browse +// --------------------------------------------------------------------------- +// Dashboard -> gs -> Sync -> sync lifecycle -> complete -> verify summary + +#[test] +fn test_flow_sync_and_browse() { + let mut app = test_app(); + load_dashboard(&mut app); + + // Navigate to Sync via g-prefix. + send_go(&mut app, 's'); + assert!( + app.navigation.is_at(&Screen::Sync), + "gs should navigate to Sync" + ); + + // Start sync. + app.update(Msg::SyncStarted); + assert!(app.state.sync.is_running()); + + // Progress updates. + app.update(Msg::SyncProgress { + stage: "Fetching issues".into(), + current: 10, + total: 42, + }); + assert_eq!(app.state.sync.lanes[0].current, 10); + assert_eq!(app.state.sync.lanes[0].total, 42); + + app.update(Msg::SyncProgress { + stage: "Fetching merge requests".into(), + current: 5, + total: 28, + }); + assert_eq!(app.state.sync.lanes[1].current, 5); + + // Complete sync. + app.update(Msg::SyncCompleted { elapsed_ms: 5000 }); + assert!(app.state.sync.summary.is_some()); + assert_eq!(app.state.sync.summary.as_ref().unwrap().elapsed_ms, 5000); + + // Navigate to issue list to browse updated data. + send_go(&mut app, 'i'); + assert!(app.navigation.is_at(&Screen::IssueList)); + load_issue_list(&mut app); + assert_eq!(app.state.issue_list.rows.len(), 3); +} + +// --------------------------------------------------------------------------- +// Flow 5: Who / Expert Navigation +// --------------------------------------------------------------------------- +// Dashboard -> gw -> Who screen -> verify expert mode default -> inject data + +#[test] +fn test_flow_find_expert() { + let mut app = test_app(); + load_dashboard(&mut app); + + // Navigate to Who via g-prefix. + send_go(&mut app, 'w'); + assert!( + app.navigation.is_at(&Screen::Who), + "gw should navigate to Who" + ); + + // Default mode should be Expert. + assert_eq!( + app.state.who.mode, + lore_tui::state::who::WhoMode::Expert, + "Who should start in Expert mode" + ); + + // Navigate back and verify dashboard is preserved. + send_key(&mut app, KeyCode::Escape); + assert!(app.navigation.is_at(&Screen::Dashboard)); + assert_eq!(app.state.dashboard.counts.issues_total, 42); +} + +// --------------------------------------------------------------------------- +// Flow 6: Command Palette +// --------------------------------------------------------------------------- +// Any screen -> Ctrl+P -> type -> select -> verify navigation + +#[test] +fn test_flow_command_palette() { + let mut app = test_app(); + load_dashboard(&mut app); + + // Open command palette. + send_key_mod(&mut app, KeyCode::Char('p'), Modifiers::CTRL); + assert!( + matches!(app.input_mode, InputMode::Palette), + "Ctrl+P should open command palette" + ); + assert!(app.state.command_palette.query_focused); + + // Type a filter — palette should have entries. + assert!( + !app.state.command_palette.filtered.is_empty(), + "Palette should have entries when opened" + ); + + // Close palette with Esc. + send_key(&mut app, KeyCode::Escape); + assert!( + matches!(app.input_mode, InputMode::Normal), + "Esc should close palette and return to Normal mode" + ); +} + +// --------------------------------------------------------------------------- +// Flow 7: Timeline Navigation +// --------------------------------------------------------------------------- +// Dashboard -> gt -> Timeline -> inject events -> verify events -> Esc back + +#[test] +fn test_flow_timeline_navigate() { + let mut app = test_app(); + load_dashboard(&mut app); + + // Navigate to Timeline via g-prefix. + send_go(&mut app, 't'); + assert!( + app.navigation.is_at(&Screen::Timeline), + "gt should navigate to Timeline" + ); + + // Inject timeline events. + load_timeline(&mut app); + assert_eq!(app.state.timeline.events.len(), 2); + assert_eq!(app.state.timeline.events[0].summary, "Issue #101 created"); + + // Navigate to the entity from the first event via Msg. + let event_key = app.state.timeline.events[0].entity_key.clone(); + app.update(Msg::NavigateTo(Screen::IssueDetail(event_key.clone()))); + load_issue_detail(&mut app, event_key); + assert!(matches!(app.navigation.current(), Screen::IssueDetail(_))); + + // Esc back to Timeline — events should be preserved. + send_key(&mut app, KeyCode::Escape); + assert!(app.navigation.is_at(&Screen::Timeline)); + assert_eq!(app.state.timeline.events.len(), 2); +} + +// --------------------------------------------------------------------------- +// Flow 8: Bootstrap → Sync → Dashboard +// --------------------------------------------------------------------------- +// Bootstrap -> gs (triggers sync) -> SyncCompleted -> auto-navigate Dashboard + +#[test] +fn test_flow_bootstrap_sync_to_dashboard() { + let mut app = test_app(); + + // Start on Bootstrap screen. + app.update(Msg::NavigateTo(Screen::Bootstrap)); + assert!(app.navigation.is_at(&Screen::Bootstrap)); + assert!(!app.state.bootstrap.sync_started); + + // User triggers sync via g-prefix. + send_go(&mut app, 's'); + assert!( + app.state.bootstrap.sync_started, + "gs on Bootstrap should set sync_started" + ); + + // Sync completes — should auto-transition to Dashboard. + app.update(Msg::SyncCompleted { elapsed_ms: 3000 }); + assert!( + app.navigation.is_at(&Screen::Dashboard), + "SyncCompleted on Bootstrap should auto-navigate to Dashboard" + ); +} + +// --------------------------------------------------------------------------- +// Flow 9: MR List → MR Detail → Back with State +// --------------------------------------------------------------------------- +// Dashboard -> gm -> MR List -> detail (via Msg) -> Esc -> verify state + +#[test] +fn test_flow_mr_drill_in_and_back() { + let mut app = test_app(); + load_dashboard(&mut app); + + // Navigate to MR list. + send_go(&mut app, 'm'); + assert!( + app.navigation.is_at(&Screen::MrList), + "gm should navigate to MR List" + ); + + // Inject MR list data. + load_mr_list(&mut app); + assert_eq!(app.state.mr_list.rows.len(), 2); + + // Set cursor to second row (draft MR). + app.state.mr_list.selected_index = 1; + + // Navigate to MR detail via Msg. + let mr_key = EntityKey::mr(1, 88); + app.update(Msg::NavigateTo(Screen::MrDetail(mr_key.clone()))); + let screen = Screen::MrDetail(mr_key.clone()); + let generation = app + .supervisor + .submit(TaskKey::LoadScreen(screen)) + .generation; + app.update(Msg::MrDetailLoaded { + generation, + key: mr_key, + data: Box::new(lore_tui::state::mr_detail::MrDetailData { + metadata: lore_tui::state::mr_detail::MrMetadata { + iid: 88, + project_path: "web/frontend".into(), + title: "WIP: Redesign settings page".into(), + description: "Settings page redesign".into(), + state: "opened".into(), + draft: true, + author: "alice".into(), + assignees: vec![], + reviewers: vec![], + labels: vec!["ui".into()], + source_branch: "redesign-settings".into(), + target_branch: "main".into(), + merge_status: "checking".into(), + created_at: 1_736_938_400_000, + updated_at: 1_736_938_400_000, + merged_at: None, + web_url: "https://gitlab.com/web/frontend/-/merge_requests/88".into(), + discussion_count: 0, + file_change_count: 5, + }, + cross_refs: vec![], + file_changes: vec![], + }), + }); + assert!(matches!(app.navigation.current(), Screen::MrDetail(_))); + + // Go back. + send_key(&mut app, KeyCode::Escape); + assert!(app.navigation.is_at(&Screen::MrList)); + + // Cursor and data preserved. + assert_eq!( + app.state.mr_list.selected_index, 1, + "MR list cursor should be preserved after back-navigation" + ); + assert_eq!(app.state.mr_list.rows.len(), 2); +}