[CP0] Config loading with Zod validation

## Background

Config loading is critical infrastructure - every CLI command needs the config. Uses Zod for schema validation with sensible defaults. Must handle missing files gracefully with typed errors.

Reference: docs/prd/checkpoint-0.md sections "Configuration Schema", "Config Resolution Order"

## Approach

**src/core/config.ts:**
```typescript
import { z } from 'zod';
import { readFileSync } from 'node:fs';
import { ConfigNotFoundError, ConfigValidationError } from './errors';
import { getConfigPath } from './paths';

export const ConfigSchema = z.object({
  gitlab: z.object({
    baseUrl: z.string().url(),
    tokenEnvVar: z.string().default('GITLAB_TOKEN'),
  }),
  projects: z.array(z.object({
    path: z.string().min(1),
  })).min(1),
  sync: z.object({
    backfillDays: z.number().int().positive().default(14),
    staleLockMinutes: z.number().int().positive().default(10),
    heartbeatIntervalSeconds: z.number().int().positive().default(30),
    cursorRewindSeconds: z.number().int().nonnegative().default(2),
    primaryConcurrency: z.number().int().positive().default(4),
    dependentConcurrency: z.number().int().positive().default(2),
  }).default({}),
  storage: z.object({
    dbPath: z.string().optional(),
    backupDir: z.string().optional(),
    compressRawPayloads: z.boolean().default(true),
  }).default({}),
  embedding: z.object({
    provider: z.literal('ollama').default('ollama'),
    model: z.string().default('nomic-embed-text'),
    baseUrl: z.string().url().default('http://localhost:11434'),
    concurrency: z.number().int().positive().default(4),
  }).default({}),
});

export type Config = z.infer<typeof ConfigSchema>;

export function loadConfig(cliOverride?: string): Config {
  const path = getConfigPath(cliOverride);
  // throws ConfigNotFoundError if missing
  // throws ConfigValidationError if invalid
}
```

## Acceptance Criteria

- [ ] `loadConfig()` returns validated Config object
- [ ] `loadConfig()` throws ConfigNotFoundError if file missing
- [ ] `loadConfig()` throws ConfigValidationError with Zod errors if invalid
- [ ] Empty optional fields get default values
- [ ] projects array must have at least 1 item
- [ ] gitlab.baseUrl must be valid URL
- [ ] All number fields must be positive integers
- [ ] tests/unit/config.test.ts passes (8 tests)

## Files

CREATE:
- src/core/config.ts
- tests/unit/config.test.ts
- tests/fixtures/mock-responses/valid-config.json
- tests/fixtures/mock-responses/invalid-config.json

## TDD Loop

RED:
```typescript
// tests/unit/config.test.ts
describe('Config', () => {
  it('loads config from file path')
  it('throws ConfigNotFoundError if file missing')
  it('throws ConfigValidationError if required fields missing')
  it('validates project paths are non-empty strings')
  it('applies default values for optional fields')
  it('loads from XDG path by default')
  it('respects GI_CONFIG_PATH override')
  it('respects --config flag override')
})
```

GREEN: Implement loadConfig() function

VERIFY: `npm run test -- tests/unit/config.test.ts`

## Edge Cases

- JSON parse error should wrap in ConfigValidationError
- Zod error messages should be human-readable
- File exists but empty → ConfigValidationError
- File has extra fields → should pass (Zod strips by default)


[CP0] Core utilities - paths, time, errors, logger

## Background

Core utilities provide the foundation for all other modules. Path resolution enables XDG-compliant config/data locations. Time utilities ensure consistent timestamp handling (ms epoch for DB, ISO for API). Error classes provide typed exceptions for clean error handling. Logger provides structured logging to stderr.

Reference: docs/prd/checkpoint-0.md sections "Config + Data Locations", "Timestamp Convention", "Error Classes", "Logging Configuration"

## Approach

**src/core/paths.ts:**
- `getConfigPath(cliOverride?)`: resolution order is CLI flag → GI_CONFIG_PATH env → XDG default → local fallback
- `getDataDir()`: uses XDG_DATA_HOME or ~/.local/share/gi
- `getDbPath(configOverride?)`: returns data dir + data.db
- `getBackupDir(configOverride?)`: returns data dir + backups/

**src/core/time.ts:**
- `isoToMs(isoString)`: converts GitLab API ISO 8601 → ms epoch
- `msToIso(ms)`: converts ms epoch → ISO 8601
- `nowMs()`: returns Date.now() for DB storage

**src/core/errors.ts:**
Error hierarchy (all extend GiError base class with code and cause):
- ConfigNotFoundError, ConfigValidationError
- GitLabAuthError, GitLabNotFoundError, GitLabRateLimitError, GitLabNetworkError
- DatabaseLockError, MigrationError
- TokenNotSetError

**src/core/logger.ts:**
- pino logger to stderr (fd 2) with pino-pretty in dev
- Child loggers: dbLogger, gitlabLogger, configLogger
- LOG_LEVEL env var support (default: info)

## Acceptance Criteria

- [ ] `getConfigPath()` returns ~/.config/gi/config.json when no overrides
- [ ] `getConfigPath()` respects GI_CONFIG_PATH env var
- [ ] `getConfigPath("./custom.json")` returns "./custom.json"
- [ ] `isoToMs("2024-01-27T00:00:00.000Z")` returns 1706313600000
- [ ] `msToIso(1706313600000)` returns "2024-01-27T00:00:00.000Z"
- [ ] All error classes have correct code property
- [ ] Logger outputs to stderr (not stdout)
- [ ] tests/unit/paths.test.ts passes
- [ ] tests/unit/errors.test.ts passes

## Files

CREATE:
- src/core/paths.ts
- src/core/time.ts
- src/core/errors.ts
- src/core/logger.ts
- tests/unit/paths.test.ts
- tests/unit/errors.test.ts

## TDD Loop

RED: Write tests first
```typescript
// tests/unit/paths.test.ts
describe('getConfigPath', () => {
  it('uses XDG_CONFIG_HOME if set')
  it('falls back to ~/.config/gi if XDG not set')
  it('prefers --config flag over environment')
  it('prefers environment over XDG default')
  it('falls back to local gi.config.json in dev')
})
```

GREEN: Implement paths.ts, errors.ts, time.ts, logger.ts

VERIFY: `npm run test -- tests/unit/paths.test.ts tests/unit/errors.test.ts`

## Edge Cases

- XDG_CONFIG_HOME may not exist - don't create, just return path
- existsSync() check for local fallback - only return if file exists
- Time conversion must handle timezone edge cases - always use UTC
- Logger must work even if pino-pretty not installed (production)


[CP1] Discussion ingestion module

## Background

Discussion ingestion fetches all discussions and notes for a single issue. It is called as part of dependent sync - only for issues whose `updated_at` has advanced beyond `discussions_synced_for_updated_at`. After successful sync, it updates the watermark to prevent redundant refetches.

## Approach

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

### Key Structs

```rust
#[derive(Debug, Default)]
pub struct IngestDiscussionsResult {
    pub discussions_fetched: usize,
    pub discussions_upserted: usize,
    pub notes_upserted: usize,
    pub system_notes_count: usize,
}
```

### Main Function

```rust
pub async fn ingest_issue_discussions(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,    // GitLab project ID
    issue_iid: i64,
    local_issue_id: i64,
    issue_updated_at: i64,     // For watermark update
) -> Result<IngestDiscussionsResult>
```

### Logic

1. Stream discussions via `client.paginate_issue_discussions()`
2. For each discussion:
   - Begin transaction
   - Store raw payload (compressed based on config)
   - Transform to NormalizedDiscussion
   - Upsert discussion
   - Get local discussion ID
   - Transform notes via `transform_notes()`
   - For each note: store raw payload, upsert note
   - Track system_notes_count
   - Commit transaction
3. After all discussions processed: `mark_discussions_synced(conn, local_issue_id, issue_updated_at)`

### Helper Functions

```rust
fn upsert_discussion(conn, discussion, payload_id) -> Result<()>
fn get_local_discussion_id(conn, project_id, gitlab_id) -> Result<i64>
fn upsert_note(conn, discussion_id, note, payload_id) -> Result<()>
fn mark_discussions_synced(conn, issue_id, issue_updated_at) -> Result<()>
```

### Critical Invariant

`discussions_synced_for_updated_at` MUST be updated only AFTER all discussions are successfully synced. This watermark prevents redundant refetches on subsequent runs.

## Acceptance Criteria

- [ ] `ingest_issue_discussions` streams all discussions for an issue
- [ ] Each discussion wrapped in transaction for atomicity
- [ ] Raw payloads stored for discussions and notes
- [ ] `discussions_synced_for_updated_at` updated after successful sync
- [ ] System notes tracked in result.system_notes_count
- [ ] Notes linked to correct discussion via local discussion ID

## Files

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

## TDD Loop

RED:
```rust
// tests/discussion_watermark_tests.rs
#[tokio::test] async fn fetches_discussions_when_updated_at_advanced()
#[tokio::test] async fn updates_watermark_after_successful_discussion_sync()
#[tokio::test] async fn does_not_update_watermark_on_discussion_sync_failure()
#[tokio::test] async fn stores_raw_payload_for_each_discussion()
#[tokio::test] async fn stores_raw_payload_for_each_note()
```

GREEN: Implement ingest_issue_discussions with watermark logic

VERIFY: `cargo test discussion_watermark`

## Edge Cases

- Issue with 0 discussions - mark synced anyway (empty is valid)
- Discussion with 0 notes - should not happen per GitLab API (discussions always have >= 1 note)
- Network failure mid-sync - watermark NOT updated, next run retries
- individual_note=true discussions - have exactly 1 note


[CP1] Discussion and note transformers

Transform GitLab discussion/note payloads to normalized database schema.

## Module
src/gitlab/transformers/discussion.rs

## Structs

### NormalizedDiscussion
- gitlab_discussion_id: String
- project_id: i64
- issue_id: i64
- noteable_type: String ("Issue")
- individual_note: bool
- first_note_at, last_note_at: Option<i64>
- last_seen_at: i64
- resolvable, resolved: bool

### NormalizedNote
- gitlab_id: i64
- project_id: i64
- note_type: Option<String>
- is_system: bool
- author_username: String
- body: String
- created_at, updated_at, last_seen_at: i64
- position: i32 (array index in notes[])
- resolvable, resolved: bool
- resolved_by: Option<String>
- resolved_at: Option<i64>

## Functions

### transform_discussion(gitlab_discussion, local_project_id, local_issue_id) -> NormalizedDiscussion
- Compute first_note_at/last_note_at from notes array min/max created_at
- Compute resolvable (any note resolvable)
- Compute resolved (resolvable AND all resolvable notes resolved)

### transform_notes(gitlab_discussion, local_project_id) -> Vec<NormalizedNote>
- Enumerate notes to get position (array index)
- Set is_system from note.system
- Convert timestamps to ms epoch

Files: src/gitlab/transformers/discussion.rs
Tests: tests/discussion_transformer_tests.rs
Done when: Unit tests pass for discussion/note transformation with system note flagging


[CP1] gi show issue command

Show issue details with discussions.

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

## Clap Definition
Show {
    #[arg(value_parser = ["issue", "mr"])]
    entity: String,

    iid: i64,

    #[arg(long)]
    project: Option<String>,
}

## Output Format
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?

## Ambiguity Handling
If multiple projects have same iid, either:
- Prompt for --project flag
- Show error listing which projects have that iid

Files: src/cli/commands/show.rs
Done when: Issue detail view displays all fields including threaded discussions


[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


[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