Epic: CP2 Gate E - CLI Complete

## Background
Gate E validates all CLI commands are functional and user-friendly. This is the final usability gate - even if all data is correct, users need good CLI UX to access it.

## Acceptance Criteria (Pass/Fail)

### List Command
- [ ] `gi list mrs` shows MR table with columns: iid, title, state, author, branches, updated
- [ ] `gi list mrs --state=opened` filters to only opened MRs
- [ ] `gi list mrs --state=merged` filters to only merged MRs
- [ ] `gi list mrs --state=closed` filters to only closed MRs
- [ ] `gi list mrs --state=locked` filters locally (not server-side filter)
- [ ] `gi list mrs --draft` shows only draft MRs
- [ ] `gi list mrs --no-draft` excludes draft MRs
- [ ] Draft MRs show `[DRAFT]` prefix in title column
- [ ] `gi list mrs --author=username` filters by author
- [ ] `gi list mrs --assignee=username` filters by assignee
- [ ] `gi list mrs --reviewer=username` filters by reviewer
- [ ] `gi list mrs --target-branch=main` filters by target branch
- [ ] `gi list mrs --source-branch=feature/x` filters by source branch
- [ ] `gi list mrs --label=bugfix` filters by label
- [ ] `gi list mrs --limit=N` limits output

### Show Command
- [ ] `gi show mr <iid>` displays full MR detail
- [ ] Show includes: title, description, state, draft status, author
- [ ] Show includes: assignees, reviewers, labels
- [ ] Show includes: source_branch, target_branch
- [ ] Show includes: detailed_merge_status (e.g., "mergeable")
- [ ] Show includes: merge_user and merged_at for merged MRs
- [ ] Show includes: discussions with author and date
- [ ] DiffNote shows file context: `[src/file.ts:45]`
- [ ] Multi-line DiffNote shows range: `[src/file.ts:45-48]`
- [ ] Resolved discussions show `[RESOLVED]` marker

### Count Command
- [ ] `gi count mrs` shows total count
- [ ] Count shows state breakdown: opened, merged, closed

### Sync Status
- [ ] `gi sync-status` shows MR cursor position
- [ ] Sync status shows last sync timestamp

## Validation Script
```bash
#!/bin/bash
set -e

DB_PATH="${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3"

echo "=== Gate E: CLI Complete ==="

# 1. Test list command (basic)
echo "Step 1: Basic list..."
gi list mrs --limit=5 || { echo "FAIL: list mrs failed"; exit 1; }

# 2. Test state filters
echo "Step 2: State filters..."
for state in opened merged closed; do
  echo "  Testing --state=$state"
  gi list mrs --state=$state --limit=3 || echo "  Warning: No $state MRs"
done

# 3. Test draft filters
echo "Step 3: Draft filters..."
gi list mrs --draft --limit=3 || echo "  Note: No draft MRs found"
gi list mrs --no-draft --limit=3 || echo "  Note: All MRs are drafts?"

# 4. Check [DRAFT] prefix
echo "Step 4: Check [DRAFT] prefix..."
DRAFT_IID=$(sqlite3 "$DB_PATH" "SELECT iid FROM merge_requests WHERE draft = 1 LIMIT 1;")
if [ -n "$DRAFT_IID" ]; then
  if gi list mrs --limit=100 | grep -q "\[DRAFT\]"; then
    echo "  PASS: [DRAFT] prefix found"
  else
    echo "  FAIL: Draft MR exists but no [DRAFT] prefix in output"
  fi
else
  echo "  Skip: No draft MRs to test"
fi

# 5. Test author/assignee/reviewer filters
echo "Step 5: User filters..."
AUTHOR=$(sqlite3 "$DB_PATH" "SELECT author_username FROM merge_requests LIMIT 1;")
if [ -n "$AUTHOR" ]; then
  echo "  Testing --author=$AUTHOR"
  gi list mrs --author="$AUTHOR" --limit=3
fi

REVIEWER=$(sqlite3 "$DB_PATH" "SELECT username FROM mr_reviewers LIMIT 1;")
if [ -n "$REVIEWER" ]; then
  echo "  Testing --reviewer=$REVIEWER"
  gi list mrs --reviewer="$REVIEWER" --limit=3
fi

# 6. Test branch filters
echo "Step 6: Branch filters..."
TARGET=$(sqlite3 "$DB_PATH" "SELECT target_branch FROM merge_requests LIMIT 1;")
if [ -n "$TARGET" ]; then
  echo "  Testing --target-branch=$TARGET"
  gi list mrs --target-branch="$TARGET" --limit=3
fi

# 7. Test show command
echo "Step 7: Show command..."
MR_IID=$(sqlite3 "$DB_PATH" "SELECT iid FROM merge_requests LIMIT 1;")
gi show mr "$MR_IID" || { echo "FAIL: show mr failed"; exit 1; }

# 8. Test show with DiffNote context
echo "Step 8: Show with DiffNote..."
DIFFNOTE_MR=$(sqlite3 "$DB_PATH" "
  SELECT DISTINCT m.iid
  FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL
  LIMIT 1;
")
if [ -n "$DIFFNOTE_MR" ]; then
  echo "  Testing MR with DiffNotes: !$DIFFNOTE_MR"
  OUTPUT=$(gi show mr "$DIFFNOTE_MR")
  if echo "$OUTPUT" | grep -qE '\[[^]]+:[0-9]+\]'; then
    echo "  PASS: File context [path:line] found"
  else
    echo "  FAIL: DiffNote should show [path:line] context"
  fi
else
  echo "  Skip: No MRs with DiffNotes"
fi

# 9. Test count command
echo "Step 9: Count command..."
gi count mrs || { echo "FAIL: count mrs failed"; exit 1; }

# 10. Test sync-status
echo "Step 10: Sync status..."
gi sync-status || echo "  Note: sync-status may need implementation"

echo ""
echo "=== Gate E: PASSED ==="
```

## Test Commands (Quick Verification)
```bash
# List with all column types visible:
gi list mrs --limit=10

# Show a specific MR:
gi show mr 42

# Count with breakdown:
gi count mrs

# Complex filter:
gi list mrs --state=opened --reviewer=alice --target-branch=main --limit=5
```

## Expected Output Formats

### gi list mrs
```
Merge Requests (showing 5 of 1,234)

  !847  Refactor auth to use JWT tokens           merged   @johndoe   main <- feature/jwt   3d ago
  !846  Fix memory leak in websocket handler      opened   @janedoe   main <- fix/websocket 5d ago
  !845  [DRAFT] Add dark mode CSS variables       opened   @bobsmith  main <- ui/dark-mode  1w ago
  !844  Update dependencies to latest versions    closed   @alice     main <- chore/deps    2w ago
```

### gi show mr 847
```
Merge Request !847: Refactor auth to use JWT tokens
================================================================================

Project:        group/project-one
State:          merged
Draft:          No
Author:         @johndoe
Assignees:      @janedoe, @bobsmith
Reviewers:      @alice, @charlie
Labels:         enhancement, auth, reviewed
Source:         feature/jwt
Target:         main
Merge Status:   merged
Merged By:      @alice
Merged At:      2024-03-20 14:30:00

Description:
  Moving away from session cookies to JWT-based authentication...

Discussions (3):

  @janedoe (2024-03-16) [src/auth/jwt.ts:45]:
    Should we use a separate signing key for refresh tokens?

    @johndoe (2024-03-16):
      Good point. I'll add a separate key with rotation support.

  @alice (2024-03-18) [RESOLVED]:
    Looks good! Just one nit about the token expiry constant.
```

### gi count mrs
```
Merge Requests: 1,234
  opened: 89
  merged: 1,045
  closed: 100
```

## Dependencies
This gate requires:
- bd-3js (CLI commands implementation)
- All previous gates must pass first

## Edge Cases
- Ambiguous MR iid across projects: should prompt for `--project` or show error
- Very long titles: should truncate with `...` in list view
- Empty description: should show "No description" or empty section
- No discussions: should show "No discussions" message
- Unicode in titles/descriptions: should render correctly


Add embedding error variants to LoreError

## Background
Gate B introduces Ollama-dependent operations that need distinct error variants for clear diagnostics. Each error has a unique exit code, a descriptive message, and an actionable suggestion. These errors must integrate with the existing LoreError enum pattern (renamed from GiError in bd-3lc).

## Approach
Extend `src/core/error.rs` with 4 new variants per PRD Section 4.3.

**ErrorCode additions:**
```rust
pub enum ErrorCode {
    // ... existing (InternalError=1 through TransformError=13)
    OllamaUnavailable,      // exit code 14
    OllamaModelNotFound,    // exit code 15
    EmbeddingFailed,        // exit code 16
}
```

**LoreError additions:**
```rust
/// Ollama-specific connection failure. Use instead of Http for Ollama errors
/// because it includes base_url for actionable error messages.
#[error("Cannot connect to Ollama at {base_url}. Is it running?")]
OllamaUnavailable {
    base_url: String,
    #[source]
    source: Option<reqwest::Error>,
},

#[error("Ollama model '{model}' not found. Run: ollama pull {model}")]
OllamaModelNotFound { model: String },

#[error("Embedding failed for document {document_id}: {reason}")]
EmbeddingFailed { document_id: i64, reason: String },

#[error("No embeddings found. Run: lore embed")]
EmbeddingsNotBuilt,
```

**code() mapping:**
- OllamaUnavailable => ErrorCode::OllamaUnavailable
- OllamaModelNotFound => ErrorCode::OllamaModelNotFound
- EmbeddingFailed => ErrorCode::EmbeddingFailed
- EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed (shares exit code 16)

**suggestion() mapping:**
- OllamaUnavailable => "Start Ollama: ollama serve"
- OllamaModelNotFound => "Pull the model: ollama pull nomic-embed-text"
- EmbeddingFailed => "Check Ollama logs or retry with 'lore embed --retry-failed'"
- EmbeddingsNotBuilt => "Generate embeddings first: lore embed"

## Acceptance Criteria
- [ ] All 4 error variants compile
- [ ] Exit codes: OllamaUnavailable=14, OllamaModelNotFound=15, EmbeddingFailed=16
- [ ] EmbeddingsNotBuilt shares exit code 16 (mapped to ErrorCode::EmbeddingFailed)
- [ ] OllamaUnavailable has `base_url: String` and `source: Option<reqwest::Error>`
- [ ] EmbeddingFailed has `document_id: i64` and `reason: String`
- [ ] Each variant has actionable .suggestion() text per PRD
- [ ] ErrorCode Display: OLLAMA_UNAVAILABLE, OLLAMA_MODEL_NOT_FOUND, EMBEDDING_FAILED
- [ ] Robot mode JSON includes code + suggestion for each variant
- [ ] `cargo build` succeeds

## Files
- `src/core/error.rs` — extend LoreError enum + ErrorCode enum + impl blocks

## TDD Loop
RED: Add variants, `cargo build` fails on missing match arms
GREEN: Add match arms in code(), exit_code(), suggestion(), to_robot_error(), Display
VERIFY: `cargo build && cargo test error`

## Edge Cases
- OllamaUnavailable with source=None: still valid (used when no HTTP error available)
- EmbeddingFailed with document_id=0: used for batch-level failures (not per-doc)
- EmbeddingsNotBuilt vs OllamaUnavailable: former means "never ran embed", latter means "Ollama down right now"

Completed: Added 4 LoreError variants (OllamaUnavailable, OllamaModelNotFound, EmbeddingFailed, EmbeddingsNotBuilt) and 3 ErrorCode variants with exit codes 14-16. cargo build succeeds.


Epic: Gate B - Hybrid MVP

## Background
Gate B adds semantic search capabilities via Ollama embeddings and sqlite-vec vector storage. It builds on Gate A's document layer, adding the embedding pipeline, vector search, RRF-based hybrid ranking, and graceful degradation when Ollama is unavailable. Gate B is independently shippable on top of Gate A.

## Gate B Deliverables
1. Ollama-powered embedding pipeline with sqlite-vec storage
2. Hybrid search (RRF-ranked vector + lexical) with rich filtering + graceful degradation

## Bead Dependencies (execution order, after Gate A)
1. **bd-mem** — Shared backoff utility (no deps)
2. **bd-1y8** — Chunk ID encoding (no deps)
3. **bd-3ez** — RRF ranking (no deps)
4. **bd-ljf** — Embedding error variants (blocked by bd-3lc)
5. **bd-2ac** — Migration 009 embeddings (blocked by bd-hrs)
6. **bd-335** — Ollama API client (blocked by bd-ljf)
7. **bd-am7** — Embedding pipeline (blocked by bd-335, bd-2ac, bd-1y8)
8. **bd-bjo** — Vector search (blocked by bd-2ac, bd-1y8)
9. **bd-2sx** — Embed CLI (blocked by bd-am7)
10. **bd-3eu** — Hybrid search (blocked by bd-3ez, bd-bjo, bd-1k1, bd-3q2)

## Acceptance Criteria
- [ ] `lore embed` builds embeddings for all documents via Ollama
- [ ] `lore embed --retry-failed` re-attempts failed embeddings
- [ ] `lore search --mode=hybrid "query"` uses both FTS + vector
- [ ] `lore search --mode=semantic "query"` uses vector only
- [ ] Graceful degradation: Ollama down -> FTS fallback with warning
- [ ] `lore search --explain` shows vector_rank, fts_rank, rrf_score
- [ ] sqlite-vec loaded before migration 009

All Gate B sub-beads complete: backoff, chunk IDs, RRF, error variants, migration 009, Ollama client, embedding pipeline, vector search, embed CLI, hybrid search


Implement shared backoff utility

## Background
Both `dirty_sources` and `pending_discussion_fetches` tables use exponential backoff with `next_attempt_at` timestamps. Without a shared utility, each module would duplicate the backoff curve logic, risking drift. The shared backoff module ensures consistent retry behavior across all queue consumers in Gate C.

## Approach
Create `src/core/backoff.rs` per PRD Section 6.X.

**IMPORTANT — PRD-exact signature and implementation:**
```rust
use rand::Rng;

/// Compute next_attempt_at with exponential backoff and jitter.
///
/// Formula: now + min(3600000, 1000 * 2^attempt_count) * (0.9 to 1.1)
/// - Capped at 1 hour to prevent runaway delays
/// - ±10% jitter prevents synchronized retries after outages
///
/// Used by:
/// - `dirty_sources` retry scheduling (document regeneration failures)
/// - `pending_discussion_fetches` retry scheduling (API fetch failures)
///
/// Having one implementation prevents subtle divergence between queues
/// (e.g., different caps or jitter ranges).
pub fn compute_next_attempt_at(now: i64, attempt_count: i64) -> i64 {
    // Cap attempt_count to prevent overflow (2^30 > 1 hour anyway)
    let capped_attempts = attempt_count.min(30) as u32;
    let base_delay_ms = 1000_i64.saturating_mul(1 << capped_attempts);
    let capped_delay_ms = base_delay_ms.min(3_600_000); // 1 hour cap

    // Add ±10% jitter
    let jitter_factor = rand::thread_rng().gen_range(0.9..=1.1);
    let delay_with_jitter = (capped_delay_ms as f64 * jitter_factor) as i64;

    now + delay_with_jitter
}
```

**Key PRD details (must match exactly):**
- `attempt_count` parameter is `i64` (not `u32`) — matches SQLite integer type from DB columns
- Overflow prevention: `.min(30) as u32` caps before shift
- Base delay: `1000_i64.saturating_mul(1 << capped_attempts)` — uses `saturating_mul` for safety
- Cap: `3_600_000` (1 hour)
- Jitter: `gen_range(0.9..=1.1)` — inclusive range
- Return: `i64` (milliseconds epoch)

**Cargo.toml change:** Add `rand = "0.8"` to `[dependencies]`.

## Acceptance Criteria
- [ ] Single shared implementation used by both dirty_tracker and discussion_queue
- [ ] Signature: `pub fn compute_next_attempt_at(now: i64, attempt_count: i64) -> i64`
- [ ] attempt_count is i64 (matches SQLite column type), not u32
- [ ] Overflow prevention: `.min(30) as u32` before shift
- [ ] Base delay uses `1000_i64.saturating_mul(1 << capped_attempts)`
- [ ] Cap at 1 hour (3,600,000 ms)
- [ ] Jitter: `gen_range(0.9..=1.1)` inclusive range
- [ ] Exponential curve: 1s, 2s, 4s, 8s, ... up to 1h cap
- [ ] `cargo test backoff` passes

## Files
- `src/core/backoff.rs` — new file
- `src/core/mod.rs` — add `pub mod backoff;`
- `Cargo.toml` — add `rand = "0.8"`

## TDD Loop
RED: `src/core/backoff.rs` with `#[cfg(test)] mod tests`:
- `test_exponential_curve` — verify delays double each attempt (within jitter range)
- `test_cap_at_one_hour` — attempt 20+ still produces delay <= MAX_DELAY_MS * 1.1
- `test_jitter_range` — run 100 iterations, all delays within [0.9x, 1.1x] of base
- `test_first_retry_is_about_one_second` — attempt 1 produces ~1000ms delay
- `test_overflow_safety` — very large attempt_count doesn't panic
GREEN: Implement compute_next_attempt_at()
VERIFY: `cargo test backoff`

## Edge Cases
- `attempt_count` > 30: `.min(30)` caps, saturating_mul prevents overflow
- `attempt_count` = 0: not used in practice (callers pass `attempt_count + 1`)
- `attempt_count` = 1: delay is ~1 second (first retry)
- Negative attempt_count: `.min(30)` still works, shift of negative-as-u32 wraps but saturating_mul handles it

Completed: compute_next_attempt_at with exp backoff (1s base, 1h cap, +-10% jitter), i64 params matching SQLite, overflow-safe, 5 tests pass


Update ingest command for merge_requests type

## Background
CLI entry point for MR ingestion. Routes `--type=merge_requests` to the orchestrator. Must ensure `--full` resets both MR cursor AND discussion watermarks. This is the user-facing command that kicks off the entire MR sync pipeline.

## Approach
Update `src/cli/commands/ingest.rs` to handle `merge_requests` type:
1. Add `merge_requests` branch to the resource type match statement
2. Validate resource type early with helpful error message
3. Pass `full` flag through to orchestrator (it handles the watermark reset internally)

## Files
- `src/cli/commands/ingest.rs` - Add merge_requests branch to `run_ingest`

## Acceptance Criteria
- [ ] `gi ingest --type=merge_requests` runs MR ingestion successfully
- [ ] `gi ingest --type=merge_requests --full` resets cursor AND discussion watermarks
- [ ] `gi ingest --type=invalid` returns helpful error listing valid types
- [ ] Progress output shows MR counts, discussion counts, and skip counts
- [ ] Default type remains `issues` for backward compatibility
- [ ] `cargo test ingest_command` passes

## TDD Loop
RED: `gi ingest --type=merge_requests` -> "invalid type: merge_requests"
GREEN: Add merge_requests to match statement in run_ingest
VERIFY: `gi ingest --type=merge_requests --help` shows merge_requests as valid

## Function Signature
```rust
pub async fn run_ingest(
    config: &Config,
    args: &IngestArgs,
) -> Result<(), GiError>
```

## IngestArgs Reference (existing)
```rust
#[derive(Parser, Debug)]
pub struct IngestArgs {
    /// Resource type to ingest
    #[arg(long, short = 't', default_value = "issues")]
    pub r#type: String,

    /// Filter to specific project (by path or ID)
    #[arg(long, short = 'p')]
    pub project: Option<String>,

    /// Force run even if another ingest is in progress
    #[arg(long, short = 'f')]
    pub force: bool,

    /// Full sync - reset cursor and refetch all
    #[arg(long)]
    pub full: bool,
}
```

## Code Change
```rust
use crate::core::errors::GiError;
use crate::ingestion::orchestrator::Orchestrator;

pub async fn run_ingest(
    config: &Config,
    args: &IngestArgs,
) -> Result<(), GiError> {
    let resource_type = args.r#type.as_str();

    // Validate resource type early
    match resource_type {
        "issues" | "merge_requests" => {}
        _ => {
            return Err(GiError::InvalidArgument {
                name: "type".to_string(),
                value: resource_type.to_string(),
                expected: "issues or merge_requests".to_string(),
            });
        }
    }

    // Acquire single-flight lock (unless --force)
    if !args.force {
        acquire_ingest_lock(config, resource_type)?;
    }

    // Get projects to ingest (filtered if --project specified)
    let projects = get_projects_to_ingest(config, args.project.as_deref())?;

    for project in projects {
        println!("Ingesting {} for {}...", resource_type, project.path);

        let orchestrator = Orchestrator::new(
            &config,
            project.id,
            project.gitlab_id,
        )?;

        let result = orchestrator.run_ingestion(resource_type, args.full).await?;

        // Print results based on resource type
        match resource_type {
            "issues" => {
                println!("  {}: {} issues fetched, {} upserted",
                    project.path, result.issues_fetched, result.issues_upserted);
            }
            "merge_requests" => {
                println!("  {}: {} MRs fetched, {} new labels, {} assignees, {} reviewers",
                    project.path,
                    result.mrs_fetched,
                    result.labels_created,
                    result.assignees_linked,
                    result.reviewers_linked,
                );
                println!("  Discussions: {} synced, {} notes ({} DiffNotes)",
                    result.discussions_synced,
                    result.notes_synced,
                    result.diffnotes_count,
                );
                if result.mrs_skipped_discussion_sync > 0 {
                    println!("  Skipped discussion sync for {} unchanged MRs",
                        result.mrs_skipped_discussion_sync);
                }
                if result.failed_discussion_syncs > 0 {
                    eprintln!("  Warning: {} MRs failed discussion sync (will retry next run)",
                        result.failed_discussion_syncs);
                }
            }
            _ => unreachable!(),
        }
    }

    // Release lock
    if !args.force {
        release_ingest_lock(config, resource_type)?;
    }

    Ok(())
}
```

## Output Format
```
Ingesting merge_requests for group/project-one...
  group/project-one: 567 MRs fetched, 12 new labels, 89 assignees, 45 reviewers
  Discussions: 456 synced, 1,234 notes (89 DiffNotes)
  Skipped discussion sync for 444 unchanged MRs

Total: 567 MRs, 456 discussions, 1,234 notes
```

## Full Sync Behavior
When `--full` is passed:
1. MR cursor reset to NULL (handled by `ingest_merge_requests` with `full_sync: true`)
2. Discussion watermarks reset to NULL (handled by `reset_discussion_watermarks` called from ingestion)
3. All MRs re-fetched from GitLab API
4. All discussions re-fetched for every MR

## Error Types (from GiError enum)
```rust
// In src/core/errors.rs
pub enum GiError {
    InvalidArgument {
        name: String,
        value: String,
        expected: String,
    },
    LockError {
        resource: String,
        message: String,
    },
    // ... other variants
}
```

## Edge Cases
- Default type is `issues` for backward compatibility with CP1
- Project filter (`--project`) can limit to specific project by path or ID
- Force flag (`--force`) bypasses single-flight lock for debugging
- If no projects configured, return helpful error about running `gi project add` first
- Empty project (no MRs): completes successfully with "0 MRs fetched"


[CP1] gi show issue command

## Background

The `gi show issue <iid>` command displays detailed information about a single issue including metadata, description, labels, and all discussions with their notes. It provides a complete view similar to the GitLab web UI.

## Approach

### Module: src/cli/commands/show.rs

### Clap Definition

```rust
#[derive(Args)]
pub struct ShowArgs {
    /// Entity type
    #[arg(value_parser = ["issue", "mr"])]
    pub entity: String,

    /// Entity IID
    pub iid: i64,

    /// Project path (required if ambiguous)
    #[arg(long)]
    pub project: Option<String>,
}
```

### Handler Function

```rust
pub async fn handle_show(args: ShowArgs, conn: &Connection) -> Result<()>
```

### Logic (for entity="issue")

1. **Find issue**: Query by iid, optionally filtered by project
   - If multiple projects have same iid, require --project or error
2. **Load metadata**: title, state, author, created_at, updated_at, web_url
3. **Load labels**: JOIN through issue_labels to labels table
4. **Load discussions**: All discussions for this issue
5. **Load notes**: All notes for each discussion, ordered by position
6. **Format output**: Rich display with sections

### Output Format (matches PRD)

```
Issue #1234: Authentication redesign
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Project:  group/project-one
State:    opened
Author:   @johndoe
Created:  2024-01-15
Updated:  2024-03-20
Labels:   enhancement, auth
URL:      https://gitlab.example.com/group/project-one/-/issues/1234

Description:
  We need to redesign the authentication flow to support...

Discussions (5):

  @janedoe (2024-01-16):
    I agree we should move to JWT-based auth...

    @johndoe (2024-01-16):
      What about refresh token strategy?

  @bobsmith (2024-01-17):
    Have we considered OAuth2?
```

### Queries

```sql
-- Find issue
SELECT i.*, p.path as project_path
FROM issues i
JOIN projects p ON i.project_id = p.id
WHERE i.iid = ? AND (p.path = ? OR ? IS NULL)

-- Get labels
SELECT l.name FROM labels l
JOIN issue_labels il ON l.id = il.label_id
WHERE il.issue_id = ?

-- Get discussions with notes
SELECT d.*, n.* FROM discussions d
JOIN notes n ON d.id = n.discussion_id
WHERE d.issue_id = ?
ORDER BY d.first_note_at, n.position
```

## Acceptance Criteria

- [ ] Shows issue metadata (title, state, author, dates, URL)
- [ ] Shows labels as comma-separated list
- [ ] Shows description (truncated if very long)
- [ ] Shows discussions grouped with notes indented
- [ ] Handles --project filter correctly
- [ ] Errors clearly if iid is ambiguous without --project

## Files

- src/cli/commands/mod.rs (add `pub mod show;`)
- src/cli/commands/show.rs (create)
- src/cli/mod.rs (add Show variant to Commands enum)

## TDD Loop

RED:
```rust
#[tokio::test] async fn show_issue_displays_metadata()
#[tokio::test] async fn show_issue_displays_labels()
#[tokio::test] async fn show_issue_displays_discussions()
#[tokio::test] async fn show_issue_requires_project_when_ambiguous()
```

GREEN: Implement handler with queries and formatting

VERIFY: `cargo test show_issue`

## Edge Cases

- Issue with no labels - show "Labels: (none)"
- Issue with no discussions - show "Discussions: (none)"
- Issue with very long description - truncate with "..."
- System notes in discussions - filter out or show with [system] prefix
- Individual notes (not threaded) - show without reply indentation

Implemented gi show issue command with metadata, labels, and discussions display


[CP1] Ingestion orchestrator

## Background

The ingestion orchestrator coordinates issue sync followed by dependent discussion sync. It implements the CP1 canonical pattern: fetch issues, identify which need discussion sync (updated_at advanced), then execute discussion sync with bounded concurrency.

## Approach

### Module: src/ingestion/orchestrator.rs

### Main Function

```rust
pub async fn ingest_project_issues(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,
) -> Result<IngestProjectResult>

#[derive(Debug, Default)]
pub struct IngestProjectResult {
    pub issues_fetched: usize,
    pub issues_upserted: usize,
    pub labels_created: usize,
    pub discussions_fetched: usize,
    pub notes_fetched: usize,
    pub system_notes_count: usize,
    pub issues_skipped_discussion_sync: usize,
}
```

### Orchestration Steps

1. **Call issue ingestion**: `ingest_issues(conn, client, config, project_id, gitlab_project_id)`
2. **Get issues needing discussion sync**: From IngestIssuesResult.issues_needing_discussion_sync
3. **Execute bounded discussion sync**:
   - Use `tokio::task::LocalSet` for single-threaded runtime
   - Respect `config.sync.dependent_concurrency` (default: 5)
   - For each IssueForDiscussionSync:
     - Call `ingest_issue_discussions(...)`
     - Aggregate results
4. **Calculate skipped count**: total_issues - issues_needing_discussion_sync.len()

### Bounded Concurrency Pattern

```rust
use futures::stream::{self, StreamExt};

let local_set = LocalSet::new();
local_set.run_until(async {
    stream::iter(issues_needing_sync)
        .map(|issue| async {
            ingest_issue_discussions(
                conn, client, config,
                project_id, gitlab_project_id,
                issue.iid, issue.local_issue_id, issue.updated_at,
            ).await
        })
        .buffer_unordered(config.sync.dependent_concurrency)
        .try_collect::<Vec<_>>()
        .await
}).await
```

Note: Single-threaded runtime means concurrency is I/O-bound, not parallel execution.

## Acceptance Criteria

- [ ] Orchestrator calls issue ingestion first
- [ ] Only issues with updated_at > discussions_synced_for_updated_at get discussion sync
- [ ] Bounded concurrency respects dependent_concurrency config
- [ ] Results aggregated from both issue and discussion ingestion
- [ ] issues_skipped_discussion_sync accurately reflects unchanged issues

## Files

- src/ingestion/mod.rs (add `pub mod orchestrator;`)
- src/ingestion/orchestrator.rs (create)

## TDD Loop

RED:
```rust
// tests/orchestrator_tests.rs
#[tokio::test] async fn orchestrates_issue_then_discussion_sync()
#[tokio::test] async fn skips_discussion_sync_for_unchanged_issues()
#[tokio::test] async fn respects_bounded_concurrency()
#[tokio::test] async fn aggregates_results_correctly()
```

GREEN: Implement orchestrator with bounded concurrency

VERIFY: `cargo test orchestrator`

## Edge Cases

- All issues unchanged - no discussion sync calls
- All issues new - all get discussion sync
- dependent_concurrency=1 - sequential discussion fetches
- Issue ingestion fails - orchestrator returns error, no discussion sync


Implement lore stats CLI command

## Background
The stats command provides visibility into the document/search/embedding pipeline health. It reports counts (DocumentStats, EmbeddingStats, FtsStats, QueueStats), verifies consistency between tables (--check), and repairs inconsistencies (--repair). This is essential for diagnosing sync issues and validating Gate A/B/C correctness.

## Approach
Create `src/cli/commands/stats.rs` per PRD Section 4.6.

**Stats structs (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct Stats {
    pub documents: DocumentStats,
    pub embeddings: EmbeddingStats,
    pub fts: FtsStats,
    pub queues: QueueStats,
}

#[derive(Debug, Serialize)]
pub struct DocumentStats {
    pub issues: usize,
    pub mrs: usize,
    pub discussions: usize,
    pub total: usize,
    pub truncated: usize,
}

#[derive(Debug, Serialize)]
pub struct EmbeddingStats {
    /// Documents with at least one embedding (chunk_index=0 exists in embedding_metadata)
    pub embedded: usize,
    pub pending: usize,
    pub failed: usize,
    /// embedded / total_documents * 100 (document-level, not chunk-level)
    pub coverage_pct: f64,
    /// Total chunks across all embedded documents
    pub total_chunks: usize,
}

#[derive(Debug, Serialize)]
pub struct FtsStats { pub indexed: usize }

#[derive(Debug, Serialize)]
pub struct QueueStats {
    pub dirty_sources: usize,
    pub dirty_sources_failed: usize,
    pub pending_discussion_fetches: usize,
    pub pending_discussion_fetches_failed: usize,
}
```

**IntegrityCheck struct (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct IntegrityCheck {
    pub documents_count: usize,
    pub fts_count: usize,
    pub embeddings_count: usize,
    pub metadata_count: usize,
    pub orphaned_embeddings: usize,
    pub hash_mismatches: usize,
    pub ok: bool,
}
```

**RepairResult struct (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct RepairResult {
    pub orphaned_embeddings_deleted: usize,
    pub stale_embeddings_cleared: usize,
    pub missing_fts_repopulated: usize,
}
```

**Core functions:**
- `run_stats(config) -> Result<Stats>` — gather all stats
- `run_integrity_check(config) -> Result<IntegrityCheck>` — verify consistency
- `run_repair(config) -> Result<RepairResult>` — fix issues

**Integrity checks (per PRD):**
1. documents count == documents_fts count
2. All `embeddings.rowid / 1000` map to valid `documents.id` (orphan detection)
3. `embedding_metadata.document_hash == documents.content_hash` for chunk_index=0 rows (staleness uses `document_hash`, NOT `chunk_hash`)

**Repair operations (PRD-exact):**
1. Delete orphaned embedding_metadata (document_id NOT IN documents)
2. Delete orphaned vec0 rows: `DELETE FROM embeddings WHERE rowid / 1000 NOT IN (SELECT id FROM documents)` — uses `rowid / 1000` for chunked scheme
3. Clear stale embeddings: find documents where `embedding_metadata.document_hash != documents.content_hash` (chunk_index=0 comparison), delete ALL chunks for those docs (range-based: `rowid >= doc_id * 1000 AND rowid < (doc_id + 1) * 1000`)
4. FTS rebuild: `INSERT INTO documents_fts(documents_fts) VALUES('rebuild')` — full rebuild, NOT optimize. PRD note: partial fix is fragile with external-content FTS; rebuild is guaranteed correct.

**CLI args (PRD-exact):**
```rust
#[derive(Args)]
pub struct StatsArgs {
    #[arg(long)]
    check: bool,
    #[arg(long, requires = "check")]
    repair: bool,  // --repair requires --check
}
```

## Acceptance Criteria
- [ ] Document counts by type: issues, mrs, discussions, total, truncated
- [ ] Embedding coverage is document-level (not chunk-level): `embedded / total * 100`
- [ ] Embedding stats include total_chunks count
- [ ] FTS indexed count reported
- [ ] Queue stats: dirty_sources + dirty_sources_failed, pending_discussion_fetches + pending_discussion_fetches_failed
- [ ] --check verifies: FTS count == documents count, orphan embeddings, hash mismatches
- [ ] Orphan detection uses `rowid / 1000` for chunked embedding scheme
- [ ] Hash mismatch uses `document_hash` (not `chunk_hash`) for document-level staleness
- [ ] --repair deletes orphaned embeddings (range-based for chunks)
- [ ] --repair clears stale metadata (document_hash != content_hash at chunk_index=0)
- [ ] --repair uses FTS `rebuild` (not `optimize`) for correct-by-construction repair
- [ ] --repair requires --check (Clap `requires` attribute)
- [ ] Human output: formatted with aligned columns
- [ ] JSON output: `{"ok": true, "data": stats}`
- [ ] `cargo build` succeeds

## Files
- `src/cli/commands/stats.rs` — new file
- `src/cli/commands/mod.rs` — add `pub mod stats;`
- `src/cli/mod.rs` — add StatsArgs, wire up stats subcommand
- `src/main.rs` — add stats command handler

## TDD Loop
RED: Integration tests:
- `test_stats_empty_db` — all counts 0, coverage 0%
- `test_stats_with_documents` — correct counts by type
- `test_integrity_check_healthy` — ok=true when consistent
- `test_integrity_check_fts_mismatch` — detects FTS/doc count divergence
- `test_integrity_check_orphan_embeddings` — detects orphaned rowids
- `test_repair_rebuilds_fts` — FTS count matches after repair
- `test_repair_cleans_orphans` — orphaned embeddings deleted
- `test_repair_clears_stale` — stale metadata cleared (doc_hash mismatch)
GREEN: Implement stats, integrity check, repair
VERIFY: `cargo build && cargo test stats`

## Edge Cases
- Empty database: all counts 0, coverage 0%, no integrity issues
- Gate A only (no embeddings table): skip embedding stats gracefully
- --repair on healthy DB: no-op, reports "no issues found" / zero counts
- FTS rebuild on large DB: may be slow
- --repair without --check: Clap rejects (requires attribute enforces dependency)

Implemented stats CLI with document counts by type, embedding coverage, FTS index count, queue stats, --check integrity (FTS mismatch, orphan embeddings, stale metadata), --repair (rebuild FTS, delete orphans, clear stale). Human + JSON output. Builds clean.


Implement MR ingestion module

## Background
MR ingestion module with cursor-based sync. Follows the same pattern as issue ingestion from CP1. Discussion sync eligibility is determined via DB query AFTER ingestion (not in-memory collection) to avoid memory growth on large projects.

## Approach
Create `src/ingestion/merge_requests.rs` with:
1. `IngestMergeRequestsResult` - Aggregated stats
2. `ingest_merge_requests()` - Main ingestion function
3. `upsert_merge_request()` - Single MR upsert
4. Helper functions for labels, assignees, reviewers, cursor management

## Files
- `src/ingestion/merge_requests.rs` - New module
- `src/ingestion/mod.rs` - Export new module
- `tests/mr_ingestion_tests.rs` - Integration tests

## Acceptance Criteria
- [ ] `IngestMergeRequestsResult` has: fetched, upserted, labels_created, assignees_linked, reviewers_linked
- [ ] `ingest_merge_requests()` returns `Result<IngestMergeRequestsResult>`
- [ ] Page-boundary cursor updates (not item-count modulo)
- [ ] Tuple-based cursor filtering: `(updated_at, gitlab_id)`
- [ ] Transaction per MR for atomicity
- [ ] Raw payload stored for each MR
- [ ] Labels: clear-and-relink pattern (removes stale)
- [ ] Assignees: clear-and-relink pattern
- [ ] Reviewers: clear-and-relink pattern
- [ ] `reset_discussion_watermarks()` for --full sync
- [ ] `cargo test mr_ingestion` passes

## TDD Loop
RED: `cargo test ingest_mr` -> module not found
GREEN: Add ingestion module with full logic
VERIFY: `cargo test mr_ingestion`

## Main Function Signature
```rust
pub async fn ingest_merge_requests(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,    // GitLab project ID
    full_sync: bool,           // Reset cursor if true
) -> Result<IngestMergeRequestsResult>
```

## Ingestion Loop (page-based)
```rust
let mut page = 1u32;
loop {
    let page_result = client.fetch_merge_requests_page(...).await?;

    for mr in &page_result.items {
        // Tuple cursor filtering
        if let (Some(cursor_ts), Some(cursor_id)) = (cursor_updated_at, cursor_gitlab_id) {
            if mr_updated_at < cursor_ts { continue; }
            if mr_updated_at == cursor_ts && mr.id <= cursor_id { continue; }
        }

        // Begin transaction
        let tx = conn.unchecked_transaction()?;

        // Store raw payload
        let payload_id = store_payload(&tx, ...)?;

        // Transform and upsert
        let transformed = transform_merge_request(&mr, project_id)?;
        let upsert_result = upsert_merge_request(&tx, &transformed.merge_request, payload_id)?;

        // Clear-and-relink labels
        clear_mr_labels(&tx, local_mr_id)?;
        for label in &labels { ... }

        // Clear-and-relink assignees
        clear_mr_assignees(&tx, local_mr_id)?;
        for username in &transformed.assignee_usernames { ... }

        // Clear-and-relink reviewers
        clear_mr_reviewers(&tx, local_mr_id)?;
        for username in &transformed.reviewer_usernames { ... }

        tx.commit()?;

        // Track for cursor
        last_updated_at = Some(mr_updated_at);
        last_gitlab_id = Some(mr.id);
    }

    // Page-boundary cursor flush
    if let (Some(updated_at), Some(gitlab_id)) = (last_updated_at, last_gitlab_id) {
        update_cursor(conn, project_id, "merge_requests", updated_at, gitlab_id)?;
    }

    if page_result.is_last_page { break; }
    page = page_result.next_page.unwrap_or(page + 1);
}
```

## Full Sync Watermark Reset
```rust
fn reset_discussion_watermarks(conn: &Connection, project_id: i64) -> Result<()> {
    conn.execute(
        "UPDATE merge_requests
         SET discussions_synced_for_updated_at = NULL,
             discussions_sync_attempts = 0,
             discussions_sync_last_error = NULL
         WHERE project_id = ?",
        [project_id],
    )?;
    Ok(())
}
```

## DB Helper Functions
- `get_cursor(conn, project_id) -> (Option<i64>, Option<i64>)` - Get (updated_at, gitlab_id)
- `update_cursor(conn, project_id, resource_type, updated_at, gitlab_id)`
- `reset_cursor(conn, project_id, resource_type)`
- `upsert_merge_request(conn, mr, payload_id) -> Result<UpsertMergeRequestResult>`
- `clear_mr_labels(conn, mr_id)`
- `link_mr_label(conn, mr_id, label_id)`
- `clear_mr_assignees(conn, mr_id)`
- `upsert_mr_assignee(conn, mr_id, username)`
- `clear_mr_reviewers(conn, mr_id)`
- `upsert_mr_reviewer(conn, mr_id, username)`

## Edge Cases
- Cursor rewind may cause refetch of already-seen MRs (tuple filtering handles this)
- Large projects: 10k+ MRs - page-based cursor prevents massive refetch on crash
- Labels/assignees/reviewers may change - clear-and-relink ensures correctness


Add Resource Events API endpoints to GitLab client

## Background
Need 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.

Existing pagination pattern in client.rs: follow Link headers with per_page=100.

## Approach
Add to src/gitlab/client.rs a generic helper and 6 endpoint methods:

1. Generic paginated fetch helper (if not already present):
```rust
async fn fetch_all_pages<T: DeserializeOwned>(&self, url: &str) -> Result<Vec<T>> {
    let mut results = Vec::new();
    let mut next_url = Some(url.to_string());
    while let Some(current_url) = next_url {
        self.rate_limiter.lock().unwrap().wait();
        let resp = self.client.get(&current_url)
            .header("PRIVATE-TOKEN", &self.token)
            .query(&[("per_page", "100")])
            .send().await?;
        // ... parse Link header for next page
        let page: Vec<T> = resp.json().await?;
        results.extend(page);
        next_url = parse_next_link(&resp_headers);
    }
    Ok(results)
}
```

2. Six endpoint methods:
```rust
pub async fn fetch_issue_state_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabStateEvent>>
pub async fn fetch_issue_label_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabLabelEvent>>
pub async fn fetch_issue_milestone_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabMilestoneEvent>>
pub async fn fetch_mr_state_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabStateEvent>>
pub async fn fetch_mr_label_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabLabelEvent>>
pub async fn fetch_mr_milestone_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabMilestoneEvent>>
```

URL patterns:
- Issues: `/api/v4/projects/{project_id}/issues/{iid}/resource_{type}_events`
- MRs: `/api/v4/projects/{project_id}/merge_requests/{iid}/resource_{type}_events`

3. Consider a convenience method that fetches all 3 event types for an entity in one call:
```rust
pub async fn fetch_all_resource_events(&self, project_id: i64, entity_type: &str, iid: i64)
    -> Result<(Vec<GitLabStateEvent>, Vec<GitLabLabelEvent>, Vec<GitLabMilestoneEvent>)>
```

## Acceptance Criteria
- [ ] All 6 endpoints construct correct URLs
- [ ] Pagination follows Link headers (handles entities with >100 events)
- [ ] Rate limiter respected for each page request
- [ ] 404 returns GitLabNotFound error (entity may have been deleted)
- [ ] Network errors wrapped in GitLabNetworkError
- [ ] Types from bd-2fm used for deserialization

## Files
- src/gitlab/client.rs (add methods + optionally generic helper)

## TDD Loop
RED: Add to tests/gitlab_client_tests.rs (or new file):
- `test_fetch_issue_state_events_url` - verify URL construction (mock or inspect)
- `test_fetch_mr_label_events_url` - verify URL construction
- 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.

GREEN: Implement the 6 methods using the generic helper

VERIFY: `cargo test gitlab_client -- --nocapture && cargo build`

## Edge Cases
- project_id here is the GitLab project ID (not local DB id) — callers must pass gitlab_project_id
- Empty results (new entity with no events) should return Ok(Vec::new()), not error
- GitLab returns 403 for projects where Resource Events API is disabled — map to appropriate error
- Very old entities may have thousands of events — pagination is essential
- Rate limiter must be called per-page, not per-entity

Added fetch_all_pages<T> 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.


Implement generic dependent fetch queue (enqueue + drain)

## Background
The 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.

## Approach
Create src/core/dependent_queue.rs with:

```rust
use rusqlite::Connection;
use super::error::Result;

/// A pending job from the dependent fetch queue.
pub struct PendingJob {
    pub id: i64,
    pub project_id: i64,
    pub entity_type: String,      // "issue" | "merge_request"
    pub entity_iid: i64,
    pub entity_local_id: i64,
    pub job_type: String,         // "resource_events" | "mr_closes_issues" | "mr_diffs"
    pub payload_json: Option<String>,
    pub attempts: i32,
}

/// Enqueue a dependent fetch job. Idempotent via UNIQUE constraint (INSERT OR IGNORE).
pub fn enqueue_job(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_iid: i64,
    entity_local_id: i64,
    job_type: &str,
    payload_json: Option<&str>,
) -> Result<bool>  // returns true if actually inserted (not deduped)

/// Claim a batch of jobs for processing. Atomically sets locked_at.
/// Only claims jobs where locked_at IS NULL AND (next_retry_at IS NULL OR next_retry_at <= now).
pub fn claim_jobs(
    conn: &Connection,
    job_type: &str,
    batch_size: usize,
) -> Result<Vec<PendingJob>>

/// Mark a job as complete (DELETE the row).
pub fn complete_job(conn: &Connection, job_id: i64) -> Result<()>

/// Mark a job as failed. Increment attempts, set next_retry_at with exponential backoff, clear locked_at.
/// Backoff: 30s * 2^(attempts-1), capped at 480s.
pub fn fail_job(conn: &Connection, job_id: i64, error: &str) -> Result<()>

/// Reclaim stale locks (locked_at older than threshold).
/// Returns count of reclaimed jobs.
pub fn reclaim_stale_locks(conn: &Connection, stale_threshold_minutes: u32) -> Result<usize>

/// Count pending jobs by job_type (for stats/progress).
pub fn count_pending_jobs(conn: &Connection) -> Result<HashMap<String, usize>>
```

Register in src/core/mod.rs: `pub mod dependent_queue;`

**Key implementation details:**
- claim_jobs uses a two-step approach: SELECT ids WHERE available, then UPDATE SET locked_at for those ids. Use a single transaction.
- enqueued_at = current time in ms epoch UTC
- locked_at = current time in ms epoch UTC when claimed
- Backoff formula: next_retry_at = now + min(30_000 * 2^(attempts-1), 480_000) ms

## Acceptance Criteria
- [ ] enqueue_job is idempotent (INSERT OR IGNORE on UNIQUE constraint)
- [ ] enqueue_job returns true on insert, false on dedup
- [ ] claim_jobs only claims unlocked, non-retrying jobs
- [ ] claim_jobs respects batch_size limit
- [ ] complete_job DELETEs the row
- [ ] fail_job increments attempts, sets next_retry_at, clears locked_at, records last_error
- [ ] Backoff: 30s, 60s, 120s, 240s, 480s (capped)
- [ ] reclaim_stale_locks clears locked_at for jobs older than threshold
- [ ] count_pending_jobs returns accurate counts by job_type

## Files
- src/core/dependent_queue.rs (new)
- src/core/mod.rs (add `pub mod dependent_queue;`)

## TDD Loop
RED: tests/dependent_queue_tests.rs (new):
- `test_enqueue_job_basic` - enqueue a job, verify it exists
- `test_enqueue_job_idempotent` - enqueue same job twice, verify single row
- `test_claim_jobs_batch` - enqueue 5, claim 3, verify 3 returned and locked
- `test_claim_jobs_skips_locked` - lock a job, claim again, verify it's skipped
- `test_claim_jobs_respects_retry_at` - set next_retry_at in future, verify skipped
- `test_claim_jobs_includes_retryable` - set next_retry_at in past, verify claimed
- `test_complete_job_deletes` - complete a job, verify gone
- `test_fail_job_backoff` - fail 3 times, verify exponential next_retry_at values
- `test_reclaim_stale_locks` - set old locked_at, reclaim, verify cleared

Setup: create_test_db() with migrations 001-011, seed project + issue.

GREEN: Implement all functions

VERIFY: `cargo test dependent_queue -- --nocapture`

## Edge Cases
- claim_jobs with batch_size=0 should return empty vec (not error)
- enqueue_job with invalid job_type will be rejected by CHECK constraint — map rusqlite error to LoreError
- fail_job on a non-existent job_id should be a no-op (job may have been completed by another path)
- reclaim_stale_locks with 0 threshold would reclaim everything — ensure threshold is reasonable (minimum 1 min)
- Timestamps must use consistent ms epoch UTC (not seconds)

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.


[CP1] gi ingest --type=issues command

## Background

The `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.

## Approach

### Module: src/cli/commands/ingest.rs

### Clap Definition

```rust
#[derive(Args)]
pub struct IngestArgs {
    /// Resource type to ingest
    #[arg(long, value_parser = ["issues", "merge_requests"])]
    pub r#type: String,

    /// Filter to single project
    #[arg(long)]
    pub project: Option<String>,

    /// Override stale sync lock
    #[arg(long)]
    pub force: bool,
}
```

### Handler Function

```rust
pub async fn handle_ingest(args: IngestArgs, config: &Config) -> Result<()>
```

### Logic

1. **Acquire single-flight lock**: `acquire_sync_lock(conn, args.force)?`
2. **Get projects to sync**:
   - If `args.project` specified, filter to that one
   - Otherwise, get all configured projects from DB
3. **For each project**:
   - Print "Ingesting issues for {project_path}..."
   - Call `ingest_project_issues(conn, client, config, project_id, gitlab_project_id)`
   - Print "{N} issues fetched, {M} new labels"
4. **Print discussion sync summary**:
   - "Fetching discussions ({N} issues with updates)..."
   - "{N} discussions, {M} notes (excluding {K} system notes)"
   - "Skipped discussion sync for {N} unchanged issues."
5. **Release lock**: Lock auto-released when handler returns

### Output Format (matches PRD)

```
Ingesting issues...

  group/project-one: 1,234 issues fetched, 45 new labels

Fetching discussions (312 issues with updates)...

  group/project-one: 312 issues → 1,234 discussions, 5,678 notes

Total: 1,234 issues, 1,234 discussions, 5,678 notes (excluding 1,234 system notes)
Skipped discussion sync for 922 unchanged issues.
```

## Acceptance Criteria

- [ ] Clap args parse --type, --project, --force correctly
- [ ] Single-flight lock acquired before sync starts
- [ ] Lock error message is clear if concurrent run attempted
- [ ] Progress output shows per-project counts
- [ ] Summary includes unchanged issues skipped count
- [ ] --force flag allows overriding stale lock

## Files

- src/cli/commands/mod.rs (add `pub mod ingest;`)
- src/cli/commands/ingest.rs (create)
- src/cli/mod.rs (add Ingest variant to Commands enum)

## TDD Loop

RED:
```rust
// tests/cli_ingest_tests.rs
#[tokio::test] async fn ingest_issues_acquires_lock()
#[tokio::test] async fn ingest_issues_fails_on_concurrent_run()
#[tokio::test] async fn ingest_issues_respects_project_filter()
#[tokio::test] async fn ingest_issues_force_overrides_stale_lock()
```

GREEN: Implement handler with lock and orchestrator calls

VERIFY: `cargo test cli_ingest`

## Edge Cases

- No projects configured - return early with helpful message
- Project filter matches nothing - error with "project not found"
- Lock already held - clear error "Sync already in progress"
- Ctrl-C during sync - lock should be released (via Drop or SIGINT handler)


[CP1] GitLab client pagination methods

## Background

GitLab 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.

## Approach

Add pagination methods to GitLabClient using `async-stream` crate:

### Methods to Add

```rust
impl GitLabClient {
    /// Paginate through issues for a project.
    pub fn paginate_issues(
        &self,
        gitlab_project_id: i64,
        updated_after: Option<i64>,      // ms epoch cursor
        cursor_rewind_seconds: u32,
    ) -> Pin<Box<dyn Stream<Item = Result<GitLabIssue>> + Send + '_>>

    /// Paginate through discussions for an issue.
    pub fn paginate_issue_discussions(
        &self,
        gitlab_project_id: i64,
        issue_iid: i64,
    ) -> Pin<Box<dyn Stream<Item = Result<GitLabDiscussion>> + Send + '_>>

    /// Make request and return response with headers for pagination.
    async fn request_with_headers<T: DeserializeOwned>(
        &self,
        path: &str,
        params: &[(&str, String)],
    ) -> Result<(T, HeaderMap)>
}
```

### Pagination Logic

1. Start at page 1, per_page=100
2. For issues: add scope=all, state=all, order_by=updated_at, sort=asc
3. Apply cursor rewind: `updated_after = cursor - rewind_seconds` (clamped to 0)
4. Yield each item from response
5. Check `x-next-page` header for continuation
6. Stop when header is empty/absent OR response is empty

### Cursor Rewind

```rust
if let Some(ts) = updated_after {
    let rewind_ms = (cursor_rewind_seconds as i64) * 1000;
    let rewound = (ts - rewind_ms).max(0);  // Clamp to avoid underflow
    // Convert to ISO 8601 for updated_after param
}
```

## Acceptance Criteria

- [ ] `paginate_issues` returns Stream of GitLabIssue
- [ ] `paginate_issues` adds scope=all, state=all, order_by=updated_at, sort=asc
- [ ] `paginate_issues` applies cursor rewind with max(0) clamping
- [ ] `paginate_issue_discussions` returns Stream of GitLabDiscussion
- [ ] Both methods follow x-next-page header until empty
- [ ] Both methods stop on empty response (fallback)
- [ ] `request_with_headers` returns (T, HeaderMap) tuple

## Files

- src/gitlab/client.rs (edit - add methods)

## TDD Loop

RED:
```rust
// tests/pagination_tests.rs
#[tokio::test] async fn fetches_all_pages_when_multiple_exist()
#[tokio::test] async fn respects_per_page_parameter()
#[tokio::test] async fn follows_x_next_page_header_until_empty()
#[tokio::test] async fn falls_back_to_empty_page_stop_if_headers_missing()
#[tokio::test] async fn applies_cursor_rewind_for_tuple_semantics()
#[tokio::test] async fn clamps_negative_rewind_to_zero()
```

GREEN: Implement pagination methods with async-stream

VERIFY: `cargo test pagination`

## Edge Cases

- cursor_updated_at near zero - rewind must not underflow (use max(0))
- GitLab returns empty x-next-page - treat as end of pages
- GitLab omits pagination headers entirely - use empty response as stop condition
- DateTime conversion fails - omit updated_after and fetch all (safe fallback)

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


[CP1] Final validation - Gate A through D

Run all tests and verify all internal gates pass.

## Gate A: Issues Only (Must Pass First)
- [ ] gi ingest --type=issues fetches all issues from configured projects
- [ ] Issues stored with correct schema, including last_seen_at
- [ ] Cursor-based sync is resumable (re-run fetches only new/updated)
- [ ] Incremental cursor updates every 100 issues
- [ ] Raw payloads stored for each issue
- [ ] gi list issues and gi count issues work

## Gate B: Labels Correct (Must Pass)
- [ ] Labels extracted and stored (name-only)
- [ ] Label links created correctly
- [ ] Stale label links removed on re-sync (verified with test)
- [ ] Label count per issue matches GitLab

## Gate C: Dependent Discussion Sync (Must Pass)
- [ ] Discussions fetched for issues with updated_at advancement
- [ ] Notes stored with is_system flag correctly set
- [ ] Raw payloads stored for discussions and notes
- [ ] discussions_synced_for_updated_at watermark updated after sync
- [ ] Unchanged issues skip discussion refetch (verified with test)
- [ ] Bounded concurrency (dependent_concurrency respected)

## Gate D: Resumability Proof (Must Pass)
- [ ] Kill mid-run, rerun; bounded redo (cursor progress preserved)
- [ ] No redundant discussion refetch after crash recovery
- [ ] Single-flight lock prevents concurrent runs

## Final Gate (Must Pass)
- [ ] All unit tests pass (cargo test)
- [ ] All integration tests pass (mocked with wiremock)
- [ ] cargo clippy passes with no warnings
- [ ] cargo fmt --check passes
- [ ] Compiles with --release

## Validation Commands
cargo test
cargo clippy -- -D warnings
cargo fmt --check
cargo build --release

Files: All CP1 files
Done when: All gate criteria pass


Implement timeline expand phase: BFS cross-reference expansion

## Background
Step 3 of timeline query: follow entity_references to discover related entities not matched by keywords. Uses BFS traversal over the entity_references table with configurable depth.

## Approach
Add to src/core/timeline.rs:

```rust
/// Expand seed entities by following entity_references via BFS.
/// Returns discovered entities with provenance tracking.
pub fn expand_via_references(
    conn: &Connection,
    seeds: &[SeedEntity],
    depth: usize,             // --depth flag (default 1, 0 disables)
    expand_mentions: bool,    // --expand-mentions flag (default false)
    max_entities: usize,      // cap total expanded entities (default 100)
) -> Result<ExpansionResult>

pub struct ExpansionResult {
    pub expanded: Vec<ExpandedEntity>,
    pub unresolved: Vec<UnresolvedRef>,
}

pub struct ExpandedEntity {
    pub entity_type: String,
    pub local_id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub project_path: String,
    pub depth: usize,
    pub via_entity_type: String,
    pub via_entity_iid: i64,
    pub via_project: String,
    pub via_reference_type: String,
    pub via_source_method: String,
}
```

BFS algorithm:
1. Initialize frontier = set of seed entity (type, local_id) pairs
2. Initialize visited = frontier.clone()
3. For each depth level 1..=depth:
   a. Query entity_references WHERE source OR target is in frontier
   b. Filter edge types: always include 'closes' + 'related'; include 'mentioned' only if expand_mentions
   c. For each ref, the "other side" (not in visited) is a candidate
   d. Resolve candidate to entity details (iid, project_path, etc.)
   e. Skip if already visited
   f. Skip unresolved refs (target_entity_id IS NULL) — collect in unresolved list
   g. Add to expanded list with provenance
   h. Add to visited + next frontier
   i. Stop if total expanded >= max_entities
4. Return expanded entities + unresolved refs

SQL for finding adjacent entities:
```sql
-- Outgoing edges (from seed entities)
SELECT target_entity_type, target_entity_id, target_project_path, target_entity_iid,
       reference_type, source_method
FROM entity_references
WHERE source_entity_type = ?1 AND source_entity_id = ?2
  AND reference_type IN ('closes', 'related' [, 'mentioned'])

-- Incoming edges (to seed entities)
SELECT source_entity_type, source_entity_id, NULL, NULL,
       reference_type, source_method
FROM entity_references
WHERE target_entity_type = ?1 AND target_entity_id = ?2
  AND reference_type IN ('closes', 'related' [, 'mentioned'])
```

## Acceptance Criteria
- [ ] BFS correctly traverses to depth N
- [ ] depth=0 returns empty expansion (no traversal)
- [ ] Default: only 'closes' + 'related' edges followed
- [ ] --expand-mentions adds 'mentioned' edges
- [ ] Provenance tracked: which seed entity and edge type led to discovery
- [ ] Unresolved refs (target_entity_id NULL) collected but not traversed
- [ ] max_entities cap prevents explosion
- [ ] No cycles (visited set prevents re-expansion)

## Files
- src/core/timeline.rs (add expand_via_references)

## TDD Loop
RED: tests/timeline_expand_tests.rs:
- `test_expand_depth_1_follows_closes` - seed issue, MR closes it → MR expanded
- `test_expand_depth_0_returns_empty` - no expansion
- `test_expand_depth_2_two_hops` - issue → MR → issue chain
- `test_expand_skips_mentioned_by_default` - 'mentioned' edge not followed
- `test_expand_includes_mentioned_when_flag` - 'mentioned' edge followed with flag
- `test_expand_collects_unresolved` - cross-project ref in unresolved list
- `test_expand_caps_at_max_entities` - 200 reachable entities, cap at 100
- `test_expand_no_cycles` - circular reference doesn't infinite loop

Setup: create_test_db, seed entity_references with various edge types.

GREEN: Implement BFS traversal

VERIFY: `cargo test timeline_expand -- --nocapture`

## Edge Cases
- Bidirectional traversal: a 'closes' edge from MR→issue should be traversable from the issue side too (find MRs that close this issue)
- Self-referential refs (entity references itself) — visited set handles this
- Large fan-out on popular entities — max_entities cap is essential
- Entity may have been deleted since ref was created — skip missing entities gracefully


Run all tests and verify all internal gates pass.

## Gate A: Issues Only (Must Pass First)
- [ ] gi ingest --type=issues fetches all issues from configured projects
- [ ] Issues stored with correct schema, including last_seen_at
- [ ] Cursor-based sync is resumable (re-run fetches only new/updated)
- [ ] Incremental cursor updates every 100 issues
- [ ] Raw payloads stored for each issue
- [ ] gi list issues and gi count issues work

## Gate B: Labels Correct (Must Pass)
- [ ] Labels extracted and stored (name-only)
- [ ] Label links created correctly
- [ ] **Stale label links removed on re-sync** (verified with test)
- [ ] Label count per issue matches GitLab

## Gate C: Dependent Discussion Sync (Must Pass)
- [ ] Discussions fetched for issues with updated_at advancement
- [ ] Notes stored with is_system flag correctly set
- [ ] Raw payloads stored for discussions and notes
- [ ] discussions_synced_for_updated_at watermark updated after sync
- [ ] **Unchanged issues skip discussion refetch** (verified with test)
- [ ] Bounded concurrency (dependent_concurrency respected)

## Gate D: Resumability Proof (Must Pass)
- [ ] Kill mid-run, rerun; bounded redo (cursor progress preserved)
- [ ] No redundant discussion refetch after crash recovery
- [ ] Single-flight lock prevents concurrent runs

## Final Gate (Must Pass)
- [ ] All unit tests pass (cargo test)
- [ ] All integration tests pass (mocked with wiremock)
- [ ] cargo clippy passes with no warnings
- [ ] cargo fmt --check passes
- [ ] Compiles with --release

## Validation Commands
cargo test
cargo clippy -- -D warnings
cargo fmt --check
cargo build --release

## Data Integrity Checks
- SELECT COUNT(*) FROM issues matches GitLab issue count
- Every issue has a raw_payloads row
- Every discussion has a raw_payloads row
- Labels in issue_labels junction all exist in labels table
- Re-running gi ingest --type=issues fetches 0 new items
- After removing a label in GitLab and re-syncing, the link is removed

Files: All CP1 files
Done when: All gate criteria pass

All gates pass: 71 tests, clippy clean, fmt clean, release build successful


Implement 'lore file-history' command with human and robot output

## Background
lore file-history <path> shows MRs that touched a file, ordered by merge/creation date. Uses rename chain resolution (bd-1yx), file change data (bd-2yo), and cross-references (bd-3ia) for MR→issue linking.

## Approach
Create src/cli/commands/file_history.rs:

**1. Query logic:**
```rust
pub fn run_file_history(
    conn: &Connection,
    path: &str,
    project_id: Option<i64>,
    follow_renames: bool,
    merged_only: bool,
    include_discussions: bool,
    limit: usize,
) -> Result<FileHistoryResult>

pub struct FileHistoryResult {
    pub file_path: String,
    pub rename_chain: Option<Vec<String>>,
    pub mrs: Vec<FileHistoryMR>,
}

pub struct FileHistoryMR {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub author: String,
    pub change_type: String,
    pub old_path: Option<String>,
    pub merged_at: Option<i64>,
    pub created_at: i64,
    pub web_url: String,
    pub merge_commit_sha: Option<String>,
    pub discussion_count: usize,
    pub file_discussion_count: usize,
    pub closes_issues: Vec<LinkedIssue>,
    pub discussions: Option<Vec<FileDiscussion>>,  // when --discussions flag
}
```

**2. SQL query** (from spec §4.5):
```sql
SELECT mr.iid, mr.title, mr.state, mr.author_username,
       mr.merged_at, mr.created_at, mr.web_url, mr.merge_commit_sha,
       mfc.change_type, mfc.old_path,
       (SELECT COUNT(*) FROM discussions d WHERE d.merge_request_id = mr.id) AS discussion_count,
       (SELECT COUNT(*) FROM notes n JOIN discussions d ON n.discussion_id = d.id
        WHERE d.merge_request_id = mr.id AND n.position_new_path = ?1) AS file_discussion_count
FROM mr_file_changes mfc
JOIN merge_requests mr ON mr.id = mfc.merge_request_id
WHERE mfc.new_path IN (rename_chain_paths) OR mfc.old_path IN (rename_chain_paths)
ORDER BY COALESCE(mr.merged_at, mr.created_at) DESC
LIMIT ?;
```

**3. Ambiguous project handling:**
If path exists in multiple projects and -p not specified, return Ambiguous error (exit code 18).

**4. Human output:**
```
File History: src/auth/oauth.rs
(renamed from src/auth/handler.rs <- src/auth.rs)
─────────────────────────────────────────────────
!567  feat: add OAuth2 provider     MERGED  2024-03-25  @bob   modified
      Closes #234: Migrate to OAuth2
      12 discussions, 4 on this file
!612  fix: token refresh race       MERGED  2024-04-10  @alice modified
      Closes #299: OAuth2 login fails
      5 discussions, 2 on this file
```

**5. Robot JSON:**
```json
{
  "ok": true,
  "data": {
    "file_path": "src/auth/oauth.rs",
    "rename_chain": ["src/auth.rs", "src/auth/handler.rs", "src/auth/oauth.rs"],
    "mrs": [...]
  },
  "meta": { "total_mrs": 2 }
}
```

## Acceptance Criteria
- [ ] Correct MR results via rename chain resolution
- [ ] MRs ordered by merge/creation date (newest first)
- [ ] Discussion counts accurate (total + file-specific)
- [ ] Rename chain displayed in human output
- [ ] --discussions shows DiffNote snippets
- [ ] --merged filters to merged MRs only
- [ ] --no-follow-renames disables rename chain
- [ ] -p required when path in multiple projects (Ambiguous error, exit 18)
- [ ] Robot JSON includes rename_chain
- [ ] Closes issues listed from entity_references
- [ ] Graceful empty state: "No MR data found. Run lore sync with fetchMrFileChanges: true"

## Files
- src/cli/commands/file_history.rs (new)
- src/cli/commands/mod.rs (add `pub mod file_history;`)
- src/cli/mod.rs (add FileHistory variant + FileHistoryArgs)
- src/main.rs (add handler)

## TDD Loop
RED: tests/file_history_command_tests.rs:
- `test_file_history_basic` - seed file changes, verify MR results
- `test_file_history_with_rename_chain` - renamed file, verify old MRs included
- `test_file_history_merged_only` - mix of states, verify filter
- `test_file_history_empty_state` - no data, verify graceful message
- `test_file_history_ambiguous_project` - path in 2 projects without -p, verify error
- `test_file_history_robot_json` - verify JSON structure

GREEN: Implement command with query + rendering

VERIFY: `cargo test file_history -- --nocapture`

## Edge Cases
- File with 100+ MRs: respect -n limit
- Renamed file with no MRs at old path: rename chain still displayed but no extra MRs
- File discussion count query: notes.position_new_path must match — some notes may have position_old_path instead
- Deleted files: path still queryable if file_changes record exists