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


[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


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