[CP0] Project scaffold

## Background

This is the foundational scaffold for the GitLab Inbox CLI tool. Every subsequent bead depends on having the correct project structure, TypeScript configuration, and tooling in place. The configuration choices here (ESM modules, strict TypeScript, Vitest for testing) set constraints for all future code.

## Approach

Create a Node.js 20+ ESM project with TypeScript strict mode. Use flat ESLint config (v9+) with TypeScript plugin. Configure Vitest with coverage. Create the directory structure matching the PRD exactly.

**package.json essentials:**
- `"type": "module"` for ESM
- `"bin": { "gi": "./dist/cli/index.js" }` for CLI entry point
- Runtime deps: better-sqlite3, sqlite-vec, commander, zod, pino, pino-pretty, ora, chalk, cli-table3, inquirer
- Dev deps: typescript, @types/better-sqlite3, @types/node, vitest, msw, eslint, @typescript-eslint/*

**tsconfig.json:**
- `target: ES2022`, `module: Node16`, `moduleResolution: Node16`
- `strict: true`, `noImplicitAny: true`, `strictNullChecks: true`
- `outDir: ./dist`, `rootDir: ./src`

**vitest.config.ts:**
- Exclude `tests/live/**` unless `GITLAB_LIVE_TESTS=1`
- Coverage with v8 provider

## Acceptance Criteria

- [ ] `npm install` completes without errors
- [ ] `npm run build` compiles TypeScript to dist/
- [ ] `npm run test` runs vitest (0 tests is fine at this stage)
- [ ] `npm run lint` runs ESLint with no config errors
- [ ] All directories exist: src/cli/commands/, src/core/, src/gitlab/, src/types/, tests/unit/, tests/integration/, tests/live/, tests/fixtures/mock-responses/, migrations/

## Files

CREATE:
- package.json
- tsconfig.json
- vitest.config.ts
- eslint.config.js
- .gitignore
- src/cli/index.ts (empty placeholder with shebang)
- src/cli/commands/.gitkeep
- src/core/.gitkeep
- src/gitlab/.gitkeep
- src/types/index.ts (empty)
- tests/unit/.gitkeep
- tests/integration/.gitkeep
- tests/live/.gitkeep
- tests/fixtures/mock-responses/.gitkeep
- migrations/.gitkeep

## TDD Loop

N/A - scaffold only. Verify with:

```bash
npm install
npm run build
npm run lint
npm run test
```

## Edge Cases

- Node.js version < 20 will fail on ESM features - add `engines` field
- better-sqlite3 requires native compilation - may need python/build-essential
- sqlite-vec installation can fail on some platforms - document fallback


Implement MR transformer

## Background
Transforms GitLab MR API responses into normalized schema for database storage. Handles deprecated field fallbacks and extracts metadata (labels, assignees, reviewers).

## Approach
Create new transformer module following existing issue transformer pattern:
- `NormalizedMergeRequest` - Database-ready struct
- `MergeRequestWithMetadata` - MR + extracted labels/assignees/reviewers
- `transform_merge_request()` - Main transformation function
- `extract_labels()` - Label extraction helper

## Files
- `src/gitlab/transformers/merge_request.rs` - New transformer module
- `src/gitlab/transformers/mod.rs` - Export new module
- `tests/mr_transformer_tests.rs` - Unit tests

## Acceptance Criteria
- [ ] `NormalizedMergeRequest` struct exists with all DB columns
- [ ] `MergeRequestWithMetadata` contains MR + label_names + assignee_usernames + reviewer_usernames
- [ ] `transform_merge_request()` returns `Result<MergeRequestWithMetadata, String>`
- [ ] `draft` computed as `gitlab_mr.draft || gitlab_mr.work_in_progress`
- [ ] `detailed_merge_status` prefers `detailed_merge_status` over `merge_status_legacy`
- [ ] `merge_user_username` prefers `merge_user` over `merged_by`
- [ ] `head_sha` extracted from `sha` field
- [ ] `references_short` and `references_full` extracted from `references` Option
- [ ] Timestamps parsed with `iso_to_ms()`, errors returned (not zeroed)
- [ ] `last_seen_at` set to `now_ms()`
- [ ] `cargo test mr_transformer` passes

## TDD Loop
RED: `cargo test mr_transformer` -> module not found
GREEN: Add transformer with all fields
VERIFY: `cargo test mr_transformer`

## Struct Definitions
```rust
#[derive(Debug, Clone)]
pub struct NormalizedMergeRequest {
    pub gitlab_id: i64,
    pub project_id: i64,
    pub iid: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,
    pub draft: bool,
    pub author_username: String,
    pub source_branch: String,
    pub target_branch: String,
    pub head_sha: Option<String>,
    pub references_short: Option<String>,
    pub references_full: Option<String>,
    pub detailed_merge_status: Option<String>,
    pub merge_user_username: Option<String>,
    pub created_at: i64,
    pub updated_at: i64,
    pub merged_at: Option<i64>,
    pub closed_at: Option<i64>,
    pub last_seen_at: i64,
    pub web_url: String,
}

#[derive(Debug, Clone)]
pub struct MergeRequestWithMetadata {
    pub merge_request: NormalizedMergeRequest,
    pub label_names: Vec<String>,
    pub assignee_usernames: Vec<String>,
    pub reviewer_usernames: Vec<String>,
}
```

## Function Signature
```rust
pub fn transform_merge_request(
    gitlab_mr: &GitLabMergeRequest,
    local_project_id: i64,
) -> Result<MergeRequestWithMetadata, String>
```

## Key Logic
```rust
// Draft: prefer draft, fallback to work_in_progress
let is_draft = gitlab_mr.draft || gitlab_mr.work_in_progress;

// Merge status: prefer detailed_merge_status
let detailed_merge_status = gitlab_mr.detailed_merge_status
    .clone()
    .or_else(|| gitlab_mr.merge_status_legacy.clone());

// Merge user: prefer merge_user
let merge_user_username = gitlab_mr.merge_user
    .as_ref()
    .map(|u| u.username.clone())
    .or_else(|| gitlab_mr.merged_by.as_ref().map(|u| u.username.clone()));

// References extraction
let (references_short, references_full) = gitlab_mr.references
    .as_ref()
    .map(|r| (Some(r.short.clone()), Some(r.full.clone())))
    .unwrap_or((None, None));

// Head SHA
let head_sha = gitlab_mr.sha.clone();
```

## Edge Cases
- Invalid timestamps should return `Err`, not zero values
- Empty labels/assignees/reviewers should return empty Vecs, not None
- `state` must pass through as-is (including "locked")


[CP1] Discussion and note transformers

Transform GitLab discussion/note payloads to normalized database schema.

Functions to implement:
- transformDiscussion(gitlabDiscussion, localProjectId, localIssueId) → NormalizedDiscussion
- transformNotes(gitlabDiscussion, localProjectId) → NormalizedNote[]

Transformation rules:
- Compute first_note_at/last_note_at from notes array
- Compute resolvable/resolved status from notes
- Set is_system from note.system
- Preserve note order via position (array index)
- Convert ISO timestamps to ms epoch

Files: src/gitlab/transformers/discussion.ts
Tests: tests/unit/discussion-transformer.test.ts
Done when: Unit tests pass for discussion/note transformation with system note flagging


Final validation and test coverage

## Background
Final validation gate ensuring all CP2 features work correctly. Verifies tests, lint, and manual smoke tests pass.

## Approach
Run comprehensive validation:
1. Automated tests (unit + integration)
2. Clippy and formatting
3. Critical test case verification
4. Gate A/B/C/D/E checklist
5. Manual smoke tests

## Files
None - validation only

## Acceptance Criteria
- [ ] `cargo test` passes (all tests green)
- [ ] `cargo test --release` passes
- [ ] `cargo clippy -- -D warnings` passes (zero warnings)
- [ ] `cargo fmt --check` passes
- [ ] Critical tests pass (see list below)
- [ ] Gate A/B/C/D/E verification complete
- [ ] Manual smoke tests pass

## Validation Commands
```bash
# 1. Build and test
cargo build --release
cargo test --release

# 2. Lint
cargo clippy -- -D warnings
cargo fmt --check

# 3. Run specific critical tests
cargo test does_not_advance_discussion_watermark_on_partial_failure
cargo test prefers_detailed_merge_status_when_both_fields_present
cargo test prefers_merge_user_when_both_fields_present
cargo test prefers_draft_when_both_draft_and_work_in_progress_present
cargo test atomic_note_replacement_preserves_data_on_parse_failure
cargo test full_sync_resets_discussion_watermarks
```

## Critical Test Cases
| Test | What It Verifies |
|------|------------------|
| `does_not_advance_discussion_watermark_on_partial_failure` | Pagination failure doesn't lose data |
| `prefers_detailed_merge_status_when_both_fields_present` | Non-deprecated field wins |
| `prefers_merge_user_when_both_fields_present` | Non-deprecated field wins |
| `prefers_draft_when_both_draft_and_work_in_progress_present` | OR semantics for draft |
| `atomic_note_replacement_preserves_data_on_parse_failure` | Parse before delete |
| `full_sync_resets_discussion_watermarks` | --full truly refreshes |

## Gate Checklist

### Gate A: MRs Only
- [ ] `gi ingest --type=merge_requests` fetches all MRs
- [ ] MR state supports: opened, merged, closed, locked
- [ ] draft field captured with work_in_progress fallback
- [ ] detailed_merge_status used with merge_status fallback
- [ ] head_sha and references captured
- [ ] Cursor-based sync is resumable

### Gate B: Labels + Assignees + Reviewers
- [ ] Labels linked via mr_labels junction
- [ ] Stale labels removed on resync
- [ ] Assignees linked via mr_assignees
- [ ] Reviewers linked via mr_reviewers

### Gate C: Dependent Discussion Sync
- [ ] Discussions fetched for MRs with updated_at advancement
- [ ] DiffNote position metadata captured
- [ ] DiffNote SHA triplet captured
- [ ] Upsert + sweep pattern for notes
- [ ] Watermark NOT advanced on partial failure
- [ ] Unchanged MRs skip discussion refetch

### Gate D: Resumability Proof
- [ ] Kill mid-run, rerun -> bounded redo
- [ ] `--full` resets cursor AND discussion watermarks
- [ ] Single-flight lock prevents concurrent runs

### Gate E: CLI Complete
- [ ] `gi list mrs` with all filters including --draft/--no-draft
- [ ] `gi show mr <iid>` with discussions and DiffNote context
- [ ] `gi count mrs` with state breakdown
- [ ] `gi sync-status` shows MR cursors

## Manual Smoke Tests
| Command | Expected |
|---------|----------|
| `gi ingest --type=merge_requests` | Completes, shows counts |
| `gi list mrs --limit=10` | Shows 10 MRs with correct columns |
| `gi list mrs --state=merged` | Only merged MRs |
| `gi list mrs --draft` | Only draft MRs with [DRAFT] prefix |
| `gi show mr <iid>` | Full detail with discussions |
| `gi count mrs` | Count with state breakdown |
| Re-run ingest | "0 new MRs", skipped discussion count |
| `gi ingest --type=merge_requests --full` | Full resync |

## Data Integrity Checks
```sql
-- MR count matches GitLab
SELECT COUNT(*) FROM merge_requests;

-- Every MR has raw payload
SELECT COUNT(*) FROM merge_requests WHERE raw_payload_id IS NULL;
-- Should be 0

-- Labels linked correctly
SELECT m.iid, COUNT(ml.label_id)
FROM merge_requests m
LEFT JOIN mr_labels ml ON ml.merge_request_id = m.id
GROUP BY m.id;

-- DiffNotes have position metadata
SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;
```


[CP0] gi init command - interactive setup wizard

## Background

The init command is the user's first interaction with gi. It must guide them through setup, validate everything works before writing config, and leave the system in a ready-to-use state. Poor UX here will frustrate new users.

Reference: docs/prd/checkpoint-0.md section "gi init"

## Approach

**src/cli/commands/init.ts:**

Interactive flow (using inquirer):
1. Check if config exists at target path
   - If exists and no --force: prompt "Config exists. Overwrite? [y/N]"
   - If --non-interactive and config exists: exit 2
2. Prompt for GitLab base URL (validate URL format)
3. Prompt for token env var name (default: GITLAB_TOKEN)
4. Check token is set in environment
   - If not set: exit 1 with "Export GITLAB_TOKEN first"
5. Test auth with GET /api/v4/user
   - If 401: exit 1 with "Authentication failed"
   - Show "Authenticated as @username (Display Name)"
6. Prompt for project paths (comma-separated or add one at a time)
7. Validate each project with GET /api/v4/projects/:encoded_path
   - If 404: exit 1 with "Project not found: group/project"
   - Show "✓ group/project (Project Name)"
8. Write config.json to target path
9. Initialize database with migrations
10. Insert validated projects into projects table
11. Show "Setup complete! Run 'gi doctor' to verify."

**Flags:**
- `--config <path>`: Write config to specific path
- `--force`: Skip overwrite confirmation
- `--non-interactive`: Fail if prompts would be shown (for CI/scripting)

## Acceptance Criteria

- [ ] Creates config.json with valid structure
- [ ] Validates GitLab URL is reachable before writing config
- [ ] Validates token with GET /api/v4/user before writing config
- [ ] Validates each project path exists in GitLab before writing config
- [ ] Fails with exit 1 if token not set in environment
- [ ] Fails with exit 1 if GitLab auth fails
- [ ] Fails with exit 1 if any project not found
- [ ] Prompts before overwriting existing config (unless --force)
- [ ] --force skips overwrite confirmation
- [ ] --non-interactive fails if prompts would be shown
- [ ] Creates data directory and applies DB migrations
- [ ] Inserts validated projects into projects table
- [ ] tests/integration/init.test.ts passes (11 tests)

## Files

CREATE:
- src/cli/commands/init.ts
- tests/integration/init.test.ts

## TDD Loop

RED:
```typescript
// tests/integration/init.test.ts
describe('gi init', () => {
  it('creates config file with valid structure')
  it('validates GitLab URL format')
  it('validates GitLab connection before writing config')
  it('validates each project path exists in GitLab')
  it('fails if token not set')
  it('fails if GitLab auth fails')
  it('fails if any project path not found')
  it('prompts before overwriting existing config')
  it('respects --force to skip confirmation')
  it('generates config with sensible defaults')
  it('creates data directory if missing')
})
```

GREEN: Implement init.ts

VERIFY: `npm run test -- tests/integration/init.test.ts`

## Edge Cases

- User cancels at any prompt: exit 2 (user cancelled)
- Network error during validation: show specific error, exit 1
- Token has wrong scopes (no read_api): auth succeeds but project fetch fails
- Project path with special characters must be URL-encoded
- Config directory might not exist - create with mkdirSync recursive
- --non-interactive with missing env var should fail immediately


[CP1] Test fixtures for mocked GitLab responses

## Background

Test fixtures provide mocked GitLab API responses for unit and integration tests. They enable testing without a live GitLab instance and ensure consistent test data across runs.

## Approach

### Fixture Files

Create JSON fixtures that match GitLab API response shapes:

```
tests/fixtures/
├── gitlab_issue.json           # Single issue
├── gitlab_issues_page.json     # Array of issues (pagination test)
├── gitlab_discussion.json      # Single discussion with notes
└── gitlab_discussions_page.json # Array of discussions
```

### gitlab_issue.json

```json
{
  "id": 12345,
  "iid": 42,
  "project_id": 100,
  "title": "Test issue title",
  "description": "Test issue description",
  "state": "opened",
  "created_at": "2024-01-15T10:00:00.000Z",
  "updated_at": "2024-01-20T15:30:00.000Z",
  "closed_at": null,
  "author": {
    "id": 1,
    "username": "testuser",
    "name": "Test User"
  },
  "labels": ["bug", "priority::high"],
  "web_url": "https://gitlab.example.com/group/project/-/issues/42"
}
```

### gitlab_discussion.json

```json
{
  "id": "6a9c1750b37d513a43987b574953fceb50b03ce7",
  "individual_note": false,
  "notes": [
    {
      "id": 1001,
      "type": "DiscussionNote",
      "body": "First comment in thread",
      "author": { "id": 1, "username": "testuser", "name": "Test User" },
      "created_at": "2024-01-16T09:00:00.000Z",
      "updated_at": "2024-01-16T09:00:00.000Z",
      "system": false,
      "resolvable": true,
      "resolved": false,
      "resolved_by": null,
      "resolved_at": null,
      "position": null
    },
    {
      "id": 1002,
      "type": "DiscussionNote",
      "body": "Reply to first comment",
      "author": { "id": 2, "username": "reviewer", "name": "Reviewer" },
      "created_at": "2024-01-16T10:00:00.000Z",
      "updated_at": "2024-01-16T10:00:00.000Z",
      "system": false,
      "resolvable": true,
      "resolved": false,
      "resolved_by": null,
      "resolved_at": null,
      "position": null
    }
  ]
}
```

### Helper Module

```rust
// tests/fixtures/mod.rs

pub fn load_fixture<T: DeserializeOwned>(name: &str) -> T {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name);
    let content = std::fs::read_to_string(&path)
        .expect(&format!("Failed to read fixture: {}", name));
    serde_json::from_str(&content)
        .expect(&format!("Failed to parse fixture: {}", name))
}

pub fn gitlab_issue() -> GitLabIssue {
    load_fixture("gitlab_issue.json")
}

pub fn gitlab_issues_page() -> Vec<GitLabIssue> {
    load_fixture("gitlab_issues_page.json")
}

pub fn gitlab_discussion() -> GitLabDiscussion {
    load_fixture("gitlab_discussion.json")
}
```

## Acceptance Criteria

- [ ] gitlab_issue.json deserializes to GitLabIssue correctly
- [ ] gitlab_issues_page.json contains 3+ issues for pagination tests
- [ ] gitlab_discussion.json contains multi-note thread
- [ ] gitlab_discussions_page.json contains mix of individual_note true/false
- [ ] At least one fixture includes system: true note
- [ ] Helper functions load fixtures without panic

## Files

- tests/fixtures/gitlab_issue.json (create)
- tests/fixtures/gitlab_issues_page.json (create)
- tests/fixtures/gitlab_discussion.json (create)
- tests/fixtures/gitlab_discussions_page.json (create)
- tests/fixtures/mod.rs (create)

## TDD Loop

RED:
```rust
#[test] fn fixture_gitlab_issue_deserializes()
#[test] fn fixture_gitlab_discussion_deserializes()
#[test] fn fixture_has_system_note()
```

GREEN: Create JSON fixtures and helper module

VERIFY: `cargo test fixture`

## Edge Cases

- Include issue with empty labels array
- Include issue with null description
- Include system note (system: true)
- Include individual_note: true discussion (standalone comment)
- Timestamps must be valid ISO 8601

Created 4 JSON fixture files (issue, issues_page, discussion, discussions_page) with helper tests - 6 tests passing


Epic: CP2 Gate A - MRs Only

## Background
Gate A validates core MR ingestion works before adding complexity. Proves the cursor-based sync, pagination, and basic CLI work. This is the foundation - if Gate A fails, nothing else matters.

## Acceptance Criteria (Pass/Fail)
- [ ] `gi ingest --type=merge_requests` completes without error
- [ ] `SELECT COUNT(*) FROM merge_requests` > 0
- [ ] `gi list mrs --limit=5` shows 5 MRs with iid, title, state, author
- [ ] `gi count mrs` shows total count matching DB query
- [ ] MR with `state=locked` can be stored (if exists in test data)
- [ ] Draft MR shows `draft=1` in DB and `[DRAFT]` in list output
- [ ] `work_in_progress=true` MR shows `draft=1` (fallback works)
- [ ] `head_sha` populated for MRs with commits
- [ ] `references_short` and `references_full` populated
- [ ] Re-run ingest shows "0 new MRs" or minimal refetch (cursor working)
- [ ] Cursor saved at page boundary, not item boundary

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

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

echo "=== Gate A: MRs Only ==="

# 1. Clear any existing MR data for clean test
echo "Step 1: Reset MR cursor for clean test..."
sqlite3 "$DB_PATH" "DELETE FROM sync_cursors WHERE resource_type = 'merge_requests';"

# 2. Run MR ingestion
echo "Step 2: Ingest MRs..."
gi ingest --type=merge_requests

# 3. Verify MRs exist
echo "Step 3: Verify MR count..."
MR_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests;")
echo "  MR count: $MR_COUNT"
[ "$MR_COUNT" -gt 0 ] || { echo "FAIL: No MRs ingested"; exit 1; }

# 4. Verify list command
echo "Step 4: Test list command..."
gi list mrs --limit=5

# 5. Verify count command
echo "Step 5: Test count command..."
gi count mrs

# 6. Verify draft handling
echo "Step 6: Check draft MRs..."
DRAFT_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE draft = 1;")
echo "  Draft MR count: $DRAFT_COUNT"

# 7. Verify head_sha population
echo "Step 7: Check head_sha..."
SHA_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE head_sha IS NOT NULL;")
echo "  MRs with head_sha: $SHA_COUNT"

# 8. Verify references
echo "Step 8: Check references..."
REF_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE references_short IS NOT NULL;")
echo "  MRs with references: $REF_COUNT"

# 9. Verify cursor saved
echo "Step 9: Check cursor..."
CURSOR=$(sqlite3 "$DB_PATH" "SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';")
echo "  Cursor: $CURSOR"
[ -n "$CURSOR" ] || { echo "FAIL: Cursor not saved"; exit 1; }

# 10. Re-run and verify minimal refetch
echo "Step 10: Re-run ingest (should be minimal)..."
gi ingest --type=merge_requests
# Output should show minimal or zero new MRs

echo ""
echo "=== Gate A: PASSED ==="
```

## Test Commands (Quick Verification)
```bash
# Run these in order:
gi ingest --type=merge_requests
gi list mrs --limit=10
gi count mrs

# Verify in DB:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    COUNT(*) as total,
    SUM(CASE WHEN draft = 1 THEN 1 ELSE 0 END) as drafts,
    SUM(CASE WHEN head_sha IS NOT NULL THEN 1 ELSE 0 END) as with_sha,
    SUM(CASE WHEN references_short IS NOT NULL THEN 1 ELSE 0 END) as with_refs
  FROM merge_requests;
"

# Re-run (should be no-op):
gi ingest --type=merge_requests
```

## Dependencies
This gate requires these beads to be complete:
- bd-3ir (Database migration)
- bd-5ta (GitLab MR types)
- bd-34o (MR transformer)
- bd-iba (GitLab client pagination)
- bd-ser (MR ingestion module)

## Edge Cases
- `locked` state is transitional (merge in progress); may not exist in test data
- Some older GitLab instances may not return `head_sha` for all MRs
- `work_in_progress` is deprecated but should still work as fallback
- Very large projects (10k+ MRs) may take significant time on first sync


[CP1] gi count issues/discussions/notes commands

Count entities in the database.

Commands:
- gi count issues → 'Issues: N'
- gi count discussions --type=issue → 'Issue Discussions: N'
- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'

Files: src/cli/commands/count.ts
Done when: Counts match expected values from GitLab


Create mock response files for integration tests using wiremock.

## Fixtures to Create

### tests/fixtures/gitlab_issue.json
Single issue with labels:
- id, iid, project_id, title, description, state
- author object
- labels array (string names)
- timestamps
- web_url

### tests/fixtures/gitlab_issues_page.json
Array of issues simulating paginated response:
- 3-5 issues with varying states
- Mix of labels

### tests/fixtures/gitlab_discussion.json
Single discussion:
- id (string)
- individual_note: false
- notes array with 2+ notes
- Include one system note

### tests/fixtures/gitlab_discussions_page.json
Array of discussions:
- Mix of individual_note true/false
- Include resolvable/resolved examples

## Edge Cases to Cover
- Issue with no labels (empty array)
- Issue with labels_details (ignored in CP1)
- Discussion with individual_note=true (single note)
- System notes with system=true
- Resolvable notes

Files: tests/fixtures/gitlab_issue.json, gitlab_issues_page.json, gitlab_discussion.json, gitlab_discussions_page.json
Done when: wiremock handlers can use fixtures for deterministic tests


Add database migration 006_merge_requests.sql

## Background
Foundation for all CP2 MR features. This migration defines the schema that all other MR components depend on. Must complete BEFORE any other CP2 work can proceed.

## Approach
Create migration file that adds:
1. `merge_requests` table with all CP2 fields
2. `mr_labels`, `mr_assignees`, `mr_reviewers` junction tables
3. Indexes on discussions for MR queries
4. DiffNote position columns on notes table

## Files
- `migrations/006_merge_requests.sql` - New migration file
- `src/core/db.rs` - Update MIGRATIONS const to include version 6

## Acceptance Criteria
- [ ] Migration file exists at `migrations/006_merge_requests.sql`
- [ ] `merge_requests` table has columns: id, gitlab_id, project_id, iid, title, description, state, draft, author_username, source_branch, target_branch, head_sha, references_short, references_full, detailed_merge_status, merge_user_username, created_at, updated_at, merged_at, closed_at, last_seen_at, discussions_synced_for_updated_at, discussions_sync_last_attempt_at, discussions_sync_attempts, discussions_sync_last_error, web_url, raw_payload_id
- [ ] `mr_labels` junction table exists with (merge_request_id, label_id) PK
- [ ] `mr_assignees` junction table exists with (merge_request_id, username) PK
- [ ] `mr_reviewers` junction table exists with (merge_request_id, username) PK
- [ ] `idx_discussions_mr_id` and `idx_discussions_mr_resolved` indexes exist
- [ ] `notes` table has new columns: position_type, position_line_range_start, position_line_range_end, position_base_sha, position_start_sha, position_head_sha
- [ ] `gi doctor` runs without migration errors
- [ ] `cargo test` passes

## TDD Loop
RED: Cannot open DB with version 6 schema
GREEN: Add migration file with full SQL
VERIFY: `cargo run -- doctor` shows healthy DB

## SQL Reference (from PRD)
```sql
-- Merge requests table
CREATE TABLE merge_requests (
  id INTEGER PRIMARY KEY,
  gitlab_id INTEGER UNIQUE NOT NULL,
  project_id INTEGER NOT NULL REFERENCES projects(id),
  iid INTEGER NOT NULL,
  title TEXT,
  description TEXT,
  state TEXT,                         -- opened | merged | closed | locked
  draft INTEGER NOT NULL DEFAULT 0,   -- SQLite boolean
  author_username TEXT,
  source_branch TEXT,
  target_branch TEXT,
  head_sha TEXT,
  references_short TEXT,
  references_full TEXT,
  detailed_merge_status TEXT,
  merge_user_username TEXT,
  created_at INTEGER,                 -- ms epoch UTC
  updated_at INTEGER,
  merged_at INTEGER,
  closed_at INTEGER,
  last_seen_at INTEGER NOT NULL,
  discussions_synced_for_updated_at INTEGER,
  discussions_sync_last_attempt_at INTEGER,
  discussions_sync_attempts INTEGER DEFAULT 0,
  discussions_sync_last_error TEXT,
  web_url TEXT,
  raw_payload_id INTEGER REFERENCES raw_payloads(id)
);
CREATE INDEX idx_mrs_project_updated ON merge_requests(project_id, updated_at);
CREATE UNIQUE INDEX uq_mrs_project_iid ON merge_requests(project_id, iid);
-- ... (see PRD for full index list)

-- Junction tables
CREATE TABLE mr_labels (
  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,
  label_id INTEGER REFERENCES labels(id) ON DELETE CASCADE,
  PRIMARY KEY(merge_request_id, label_id)
);

CREATE TABLE mr_assignees (
  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,
  username TEXT NOT NULL,
  PRIMARY KEY(merge_request_id, username)
);

CREATE TABLE mr_reviewers (
  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,
  username TEXT NOT NULL,
  PRIMARY KEY(merge_request_id, username)
);

-- DiffNote position columns (ALTER TABLE)
ALTER TABLE notes ADD COLUMN position_type TEXT;
ALTER TABLE notes ADD COLUMN position_line_range_start INTEGER;
ALTER TABLE notes ADD COLUMN position_line_range_end INTEGER;
ALTER TABLE notes ADD COLUMN position_base_sha TEXT;
ALTER TABLE notes ADD COLUMN position_start_sha TEXT;
ALTER TABLE notes ADD COLUMN position_head_sha TEXT;

INSERT INTO schema_version (version, applied_at, description)
VALUES (6, strftime('%s', 'now') * 1000, 'Merge requests, MR labels, assignees, reviewers');
```

## Edge Cases
- SQLite does not support ADD CONSTRAINT - FK defined as nullable in CP1
- `locked` state is transitional (merge-in-progress) - store as first-class
- discussions_synced_for_updated_at prevents redundant discussion refetch

Migration 006_merge_requests.sql created and verified. Schema v6 applied successfully with all tables, indexes, and position columns.


Add transform_mr_discussion and transform_notes_with_diff_position

## Background
Extends discussion transformer for MR context. MR discussions can contain DiffNotes with file position metadata. This is critical for code review context in CP3 document generation.

## Approach
Add two new functions to existing `src/gitlab/transformers/discussion.rs`:
1. `transform_mr_discussion()` - Transform discussion with MR reference
2. `transform_notes_with_diff_position()` - Extract DiffNote position metadata

CP1 already has the polymorphic `NormalizedDiscussion` with `NoteableRef` enum - reuse that pattern.

## Files
- `src/gitlab/transformers/discussion.rs` - Add new functions
- `tests/diffnote_tests.rs` - DiffNote position extraction tests
- `tests/mr_discussion_tests.rs` - MR discussion transform tests

## Acceptance Criteria
- [ ] `transform_mr_discussion()` returns `NormalizedDiscussion` with `merge_request_id: Some(local_mr_id)`
- [ ] `transform_notes_with_diff_position()` returns `Result<Vec<NormalizedNote>, String>`
- [ ] DiffNote position fields extracted: `position_old_path`, `position_new_path`, `position_old_line`, `position_new_line`
- [ ] Extended position fields extracted: `position_type`, `position_line_range_start`, `position_line_range_end`
- [ ] SHA triplet extracted: `position_base_sha`, `position_start_sha`, `position_head_sha`
- [ ] Strict timestamp parsing - returns `Err` on invalid timestamps (no `unwrap_or(0)`)
- [ ] `cargo test diffnote` passes
- [ ] `cargo test mr_discussion` passes

## TDD Loop
RED: `cargo test diffnote_position` -> test fails
GREEN: Add position extraction logic
VERIFY: `cargo test diffnote`

## Function Signatures
```rust
/// Transform GitLab discussion for MR context.
/// Reuses existing transform_discussion logic, just with MR reference.
pub fn transform_mr_discussion(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
    local_mr_id: i64,
) -> NormalizedDiscussion {
    // Use existing transform_discussion with NoteableRef::MergeRequest(local_mr_id)
    transform_discussion(
        gitlab_discussion,
        local_project_id,
        NoteableRef::MergeRequest(local_mr_id),
    )
}

/// Transform notes with DiffNote position extraction.
/// Returns Result to enforce strict timestamp parsing.
pub fn transform_notes_with_diff_position(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
) -> Result<Vec<NormalizedNote>, String>
```

## DiffNote Position Extraction
```rust
// Extract position metadata if present
let (old_path, new_path, old_line, new_line, position_type, lr_start, lr_end, base_sha, start_sha, head_sha) = note
    .position
    .as_ref()
    .map(|pos| (
        pos.old_path.clone(),
        pos.new_path.clone(),
        pos.old_line,
        pos.new_line,
        pos.position_type.clone(),                    // "text" | "image" | "file"
        pos.line_range.as_ref().map(|r| r.start_line),
        pos.line_range.as_ref().map(|r| r.end_line),
        pos.base_sha.clone(),
        pos.start_sha.clone(),
        pos.head_sha.clone(),
    ))
    .unwrap_or((None, None, None, None, None, None, None, None, None, None));
```

## Strict Timestamp Parsing
```rust
// CRITICAL: Return error on invalid timestamps, never zero
let created_at = iso_to_ms(&note.created_at)
    .ok_or_else(|| format\!(
        "Invalid note.created_at for note {}: {}",
        note.id, note.created_at
    ))?;
```

## NormalizedNote Fields for DiffNotes
```rust
NormalizedNote {
    // ... existing fields ...
    // DiffNote position metadata
    position_old_path: old_path,
    position_new_path: new_path,
    position_old_line: old_line,
    position_new_line: new_line,
    // Extended position
    position_type,
    position_line_range_start: lr_start,
    position_line_range_end: lr_end,
    // SHA triplet
    position_base_sha: base_sha,
    position_start_sha: start_sha,
    position_head_sha: head_sha,
}
```

## Edge Cases
- Notes without position should have all position fields as None
- Invalid timestamp should fail the entire discussion (no partial results)
- File renames: `old_path \!= new_path` indicates a renamed file
- Multi-line comments: `line_range` present means comment spans lines 45-48

Implemented transform_mr_discussion() and transform_notes_with_diff_position() with full DiffNote position extraction:
- Extended NormalizedNote with 10 DiffNote position fields (path, line, type, line_range, SHA triplet)
- Added strict timestamp parsing that returns Err on invalid timestamps
- Created 13 diffnote_position_tests covering all extraction paths and error cases
- Created 6 mr_discussion_tests verifying MR reference handling
- All 161 tests passing


Implement MR CLI commands (list, show, count)

## Background
CLI commands for viewing and filtering merge requests. Includes list, show, and count commands with MR-specific filters.

## Approach
Update existing CLI command files:
1. `list.rs` - Add MR listing with filters
2. `show.rs` - Add MR detail view with discussions
3. `count.rs` - Add MR counting with state breakdown

## Files
- `src/cli/commands/list.rs` - Add MR subcommand
- `src/cli/commands/show.rs` - Add MR detail view
- `src/cli/commands/count.rs` - Add MR counting

## Acceptance Criteria
- [ ] `gi list mrs` shows MR table with iid, title, state, author, branches
- [ ] `gi list mrs --state=merged` filters by state
- [ ] `gi list mrs --state=locked` filters locally (not server-side)
- [ ] `gi list mrs --draft` shows only draft MRs
- [ ] `gi list mrs --no-draft` excludes draft MRs
- [ ] `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
- [ ] Draft MRs show `[DRAFT]` prefix in title
- [ ] `gi show mr <iid>` displays full detail including discussions
- [ ] DiffNote shows file context: `[src/file.ts:45]`
- [ ] Multi-line DiffNote shows: `[src/file.ts:45-48]`
- [ ] `gi show mr` shows `detailed_merge_status`
- [ ] `gi count mrs` shows total with state breakdown
- [ ] `gi sync-status` shows MR cursor positions
- [ ] `cargo test cli_commands` passes

## TDD Loop
RED: `cargo test list_mrs` -> command not found
GREEN: Add MR subcommand
VERIFY: `gi list mrs --help`

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

  !847  Refactor auth to use JWT tokens           merged   @johndoe   main <- feature/jwt   3 days ago
  !846  Fix memory leak in websocket handler      opened   @janedoe   main <- fix/websocket 5 days ago
  !845  [DRAFT] Add dark mode CSS variables       opened   @bobsmith  main <- ui/dark-mode  1 week ago
```

## SQL for MR Listing
```sql
SELECT
    m.iid, m.title, m.state, m.draft, m.author_username,
    m.target_branch, m.source_branch, m.updated_at
FROM merge_requests m
WHERE m.project_id = ?
  AND (? IS NULL OR m.state = ?)           -- state filter
  AND (? IS NULL OR m.draft = ?)           -- draft filter
  AND (? IS NULL OR m.author_username = ?) -- author filter
  AND (? IS NULL OR m.target_branch = ?)   -- target-branch filter
  AND (? IS NULL OR m.source_branch = ?)   -- source-branch filter
  AND (? IS NULL OR EXISTS (               -- reviewer filter
    SELECT 1 FROM mr_reviewers r
    WHERE r.merge_request_id = m.id AND r.username = ?
  ))
ORDER BY m.updated_at DESC
LIMIT ?
```

## gi show mr Output
```
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
Source:         feature/jwt
Target:         main
Merge Status:   mergeable
Merged By:      @alice
Merged At:      2024-03-20 14:30:00
Labels:         enhancement, auth, reviewed

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

Discussions (8):

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

## DiffNote File Context Display
```rust
// Build file context string
let file_context = match (note.position_new_path, note.position_new_line, note.position_line_range_end) {
    (Some(path), Some(line), Some(end_line)) if line != end_line => {
        format!("[{}:{}-{}]", path, line, end_line)
    }
    (Some(path), Some(line), _) => {
        format!("[{}:{}]", path, line)
    }
    _ => String::new(),
};
```

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

## Filter Arguments (clap)
```rust
#[derive(Parser)]
struct ListMrsArgs {
    #[arg(long)]
    state: Option<String>,  // opened|merged|closed|locked|all
    #[arg(long)]
    draft: bool,
    #[arg(long)]
    no_draft: bool,
    #[arg(long)]
    author: Option<String>,
    #[arg(long)]
    assignee: Option<String>,
    #[arg(long)]
    reviewer: Option<String>,
    #[arg(long)]
    target_branch: Option<String>,
    #[arg(long)]
    source_branch: Option<String>,
    #[arg(long)]
    label: Vec<String>,
    #[arg(long)]
    project: Option<String>,
    #[arg(long, default_value = "20")]
    limit: u32,
}
```

## Edge Cases
- `--state=locked` must filter locally (GitLab API doesn't support it)
- Ambiguous MR iid across projects: prompt for `--project`
- Empty discussions: show "No discussions" message
- Multi-line DiffNotes: show line range in context


[CP0] gi version, backup, reset, sync-status commands

## Background

These are the remaining utility commands for CP0. version is trivial. backup creates safety copies before destructive operations. reset provides clean-slate capability. sync-status is a stub for CP0 that will be implemented in CP1.

Reference: docs/prd/checkpoint-0.md sections "gi version", "gi backup", "gi reset", "gi sync-status"

## Approach

**src/cli/commands/version.ts:**
```typescript
import { Command } from 'commander';
import { version } from '../../../package.json' with { type: 'json' };

export const versionCommand = new Command('version')
  .description('Show version information')
  .action(() => {
    console.log(\`gi version \${version}\`);
  });
```

**src/cli/commands/backup.ts:**
```typescript
import { Command } from 'commander';
import { copyFileSync, mkdirSync } from 'node:fs';
import { loadConfig } from '../../core/config';
import { getDbPath, getBackupDir } from '../../core/paths';

export const backupCommand = new Command('backup')
  .description('Create timestamped database backup')
  .action(async (options, command) => {
    const globalOpts = command.optsWithGlobals();
    const config = loadConfig(globalOpts.config);

    const dbPath = getDbPath(config.storage?.dbPath);
    const backupDir = getBackupDir(config.storage?.backupDir);

    mkdirSync(backupDir, { recursive: true });

    // Format: data-2026-01-24T10-30-00.db (colons replaced for Windows compat)
    const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\\..*/, '');
    const backupPath = \`\${backupDir}/data-\${timestamp}.db\`;

    copyFileSync(dbPath, backupPath);
    console.log(\`Created backup: \${backupPath}\`);
  });
```

**src/cli/commands/reset.ts:**
```typescript
import { Command } from 'commander';
import { unlinkSync, existsSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { loadConfig } from '../../core/config';
import { getDbPath } from '../../core/paths';

export const resetCommand = new Command('reset')
  .description('Delete database and reset all state')
  .option('--confirm', 'Skip confirmation prompt')
  .action(async (options, command) => {
    const globalOpts = command.optsWithGlobals();
    const config = loadConfig(globalOpts.config);
    const dbPath = getDbPath(config.storage?.dbPath);

    if (!existsSync(dbPath)) {
      console.log('No database to reset.');
      return;
    }

    if (!options.confirm) {
      console.log(\`This will delete:\n  - Database: \${dbPath}\n  - All sync cursors\n  - All cached data\n\`);
      // Prompt for 'yes' confirmation
      // If not 'yes', exit 2
    }

    unlinkSync(dbPath);
    // Also delete WAL and SHM files if they exist
    if (existsSync(\`\${dbPath}-wal\`)) unlinkSync(\`\${dbPath}-wal\`);
    if (existsSync(\`\${dbPath}-shm\`)) unlinkSync(\`\${dbPath}-shm\`);

    console.log("Database reset. Run 'gi sync' to repopulate.");
  });
```

**src/cli/commands/sync-status.ts:**
```typescript
// CP0 stub - full implementation in CP1
export const syncStatusCommand = new Command('sync-status')
  .description('Show sync state')
  .action(() => {
    console.log("No sync runs yet. Run 'gi sync' to start.");
  });
```

## Acceptance Criteria

- [ ] `gi version` outputs "gi version X.Y.Z"
- [ ] `gi backup` creates timestamped copy of database
- [ ] Backup filename is Windows-compatible (no colons)
- [ ] Backup directory created if missing
- [ ] `gi reset` prompts for 'yes' confirmation
- [ ] `gi reset --confirm` skips prompt
- [ ] Reset deletes .db, .db-wal, and .db-shm files
- [ ] Reset exits 2 if user doesn't type 'yes'
- [ ] `gi sync-status` outputs stub message

## Files

CREATE:
- src/cli/commands/version.ts
- src/cli/commands/backup.ts
- src/cli/commands/reset.ts
- src/cli/commands/sync-status.ts

## TDD Loop

N/A - simple commands, verify manually:

```bash
gi version
gi backup
ls ~/.local/share/gi/backups/
gi reset  # type 'no'
gi reset --confirm
ls ~/.local/share/gi/data.db  # should not exist
gi sync-status
```

## Edge Cases

- Backup when database doesn't exist - show clear error
- Reset when database doesn't exist - show "No database to reset"
- WAL/SHM files may not exist - check before unlinking
- Timestamp with milliseconds could cause very long filename
- readline prompt in non-interactive terminal - handle SIGINT


[CP1] gi list issues command

List issues from the database.

Flags:
- --limit=N (default: 20)
- --project=PATH (filter by project)
- --state=opened|closed|all (default: all)

Output: Table with iid, title, state, author, relative time

Files: src/cli/commands/list.ts
Done when: List displays issues with proper filtering and formatting


[CP1] gi list issues command

## Background

The `gi list issues` command displays a paginated list of issues from the local database. It supports filtering by project and state, with configurable limit. This provides quick access to synced issues without opening GitLab.

## Approach

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

### Clap Definition

```rust
#[derive(Args)]
pub struct ListArgs {
    /// Entity type to list
    #[arg(value_parser = ["issues", "mrs"])]
    pub entity: String,

    /// Maximum results
    #[arg(long, default_value = "20")]
    pub limit: usize,

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

    /// Filter by state
    #[arg(long, value_parser = ["opened", "closed", "all"])]
    pub state: Option<String>,
}
```

### Handler Function

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

### Query (for issues)

```sql
SELECT i.iid, i.title, i.state, i.author_username, i.updated_at, p.path
FROM issues i
JOIN projects p ON i.project_id = p.id
WHERE (p.path = ? OR ? IS NULL)
  AND (i.state = ? OR ? IS NULL OR ? = 'all')
ORDER BY i.updated_at DESC
LIMIT ?
```

### Output Format (matches PRD)

```
Issues (showing 20 of 3,801)

  #1234  Authentication redesign                       opened   @johndoe   3 days ago
  #1233  Fix memory leak in cache                      closed   @janedoe   5 days ago
  #1232  Add dark mode support                         opened   @bobsmith  1 week ago
  ...
```

### Column Layout

| Column | Width | Alignment |
|--------|-------|-----------|
| IID | 6 | right |
| Title | 45 | left (truncate) |
| State | 8 | left |
| Author | 12 | left |
| Updated | 12 | right (relative) |

### Relative Time Formatting

```rust
fn format_relative_time(ms_epoch: i64) -> String {
    let now = now_ms();
    let diff = now - ms_epoch;
    match diff {
        d if d < 60_000 => "just now".to_string(),
        d if d < 3_600_000 => format!("{} min ago", d / 60_000),
        d if d < 86_400_000 => format!("{} hours ago", d / 3_600_000),
        d if d < 604_800_000 => format!("{} days ago", d / 86_400_000),
        d if d < 2_592_000_000 => format!("{} weeks ago", d / 604_800_000),
        _ => format!("{} months ago", diff / 2_592_000_000),
    }
}
```

## Acceptance Criteria

- [ ] Lists issues ordered by updated_at DESC
- [ ] Shows "showing X of Y" with total count
- [ ] Respects --limit parameter
- [ ] --project filters to single project
- [ ] --state filters to opened/closed/all
- [ ] Title truncated if longer than column width
- [ ] Updated time shown as relative ("3 days ago")

## Files

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

## TDD Loop

RED:
```rust
#[tokio::test] async fn list_issues_shows_correct_columns()
#[tokio::test] async fn list_issues_respects_limit()
#[tokio::test] async fn list_issues_filters_by_project()
#[tokio::test] async fn list_issues_filters_by_state()
```

GREEN: Implement handler with query and formatting

VERIFY: `cargo test list_issues`

## Edge Cases

- No issues match filters - show "No issues found"
- Title exactly 45 chars - no truncation
- Title 46+ chars - truncate with "..."
- --state=all shows both opened and closed
- Default state filter is all (not just opened)


[CP1] Issue transformer with label extraction

## Background

The issue transformer converts GitLab API responses into our local schema format. It extracts core fields and, critically, the array of label names from each issue. This transformer is pure logic with no I/O, making it easy to test.

## Approach

Create a transformer module with a function that:
1. Takes a `GitLabIssue` and returns an `IssueRow` struct
2. Extracts the `labels: Vec<String>` directly from the issue (GitLab returns label names as strings)

### Structs

```rust
// src/gitlab/transformers/issue.rs

pub struct IssueRow {
    pub gitlab_id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,
    pub author_username: String,
    pub created_at: i64,      // ms epoch UTC
    pub updated_at: i64,      // ms epoch UTC
    pub web_url: String,
}

pub struct IssueWithLabels {
    pub issue: IssueRow,
    pub label_names: Vec<String>,
}
```

### Function

```rust
pub fn transform_issue(issue: GitLabIssue) -> Result<IssueWithLabels, TransformError> {
    // Parse ISO 8601 timestamps to ms epoch
    // Extract author.username
    // Return IssueWithLabels with label_names from issue.labels
}
```

## Acceptance Criteria

- [ ] `IssueRow` struct exists with all fields from schema
- [ ] `IssueWithLabels` struct bundles issue + label names
- [ ] `transform_issue` parses ISO 8601 to ms epoch correctly
- [ ] `transform_issue` handles missing description (None)
- [ ] Label names are preserved exactly as received from GitLab
- [ ] Unit tests cover all edge cases

## Files

- src/gitlab/transformers/mod.rs (create, add `pub mod issue;`)
- src/gitlab/transformers/issue.rs (create)

## TDD Loop

RED:
```rust
// tests/unit/issue_transformer_test.rs
#[test] fn transforms_issue_with_all_fields()
#[test] fn handles_missing_description()
#[test] fn extracts_label_names()
#[test] fn parses_timestamps_to_ms_epoch()
```

GREEN: Implement transform_issue function

VERIFY: `cargo test issue_transformer`

## Edge Cases

- GitLab timestamps are ISO 8601 with timezone - use chrono::DateTime::parse_from_rfc3339
- Description can be null in GitLab API - map to Option<String>
- Empty labels array is valid - return empty Vec
- Do NOT parse labels_details - it varies across GitLab versions

Implemented IssueRow, IssueWithLabels, transform_issue with 6 passing unit tests covering all edge cases


[CP0] Database setup with migrations and app lock

## Background

The database is the backbone of gitlab-inbox. SQLite with WAL mode for performance, foreign keys for integrity, and proper pragmas for reliability. Migrations allow schema evolution. App lock prevents concurrent sync corruption.

Reference: docs/prd/checkpoint-0.md sections "Database Schema", "SQLite Runtime Pragmas", "App Lock Mechanism"

## Approach

**src/core/db.ts:**
```typescript
import Database from 'better-sqlite3';
import { join, dirname } from 'node:path';
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { getDbPath } from './paths';
import { dbLogger } from './logger';

export function createConnection(dbPath: string): Database.Database {
  mkdirSync(dirname(dbPath), { recursive: true });
  const db = new Database(dbPath);

  // Production-grade pragmas
  db.pragma('journal_mode = WAL');
  db.pragma('synchronous = NORMAL');
  db.pragma('foreign_keys = ON');
  db.pragma('busy_timeout = 5000');
  db.pragma('temp_store = MEMORY');

  return db;
}

export function runMigrations(db: Database.Database, migrationsDir: string): void {
  // Create schema_version table if not exists
  // Read migration files sorted by version
  // Apply migrations not yet applied
  // Track in schema_version table
}
```

**migrations/001_initial.sql:**
Full schema with tables: schema_version, projects, sync_runs, app_locks, sync_cursors, raw_payloads

**src/core/lock.ts:**
AppLock class with:
- acquire(force?): acquires lock or throws DatabaseLockError
- release(): releases lock and stops heartbeat
- Heartbeat timer that updates heartbeat_at every N seconds
- Stale lock detection (heartbeat_at > staleLockMinutes ago)

## Acceptance Criteria

- [ ] createConnection() creates parent directories if missing
- [ ] WAL mode verified: `db.pragma('journal_mode')` returns 'wal'
- [ ] Foreign keys verified: `db.pragma('foreign_keys')` returns 1
- [ ] busy_timeout verified: `db.pragma('busy_timeout')` returns 5000
- [ ] 001_initial.sql creates all 6 tables
- [ ] schema_version shows version 1 after migration
- [ ] AppLock.acquire() succeeds for first caller
- [ ] AppLock.acquire() throws DatabaseLockError for second concurrent caller
- [ ] Stale lock (heartbeat > 10 min old) can be taken over
- [ ] tests/unit/db.test.ts passes (8 tests)
- [ ] tests/integration/app-lock.test.ts passes (6 tests)

## Files

CREATE:
- src/core/db.ts
- src/core/lock.ts
- migrations/001_initial.sql
- tests/unit/db.test.ts
- tests/integration/app-lock.test.ts

## TDD Loop

RED:
```typescript
// tests/unit/db.test.ts
describe('Database', () => {
  it('creates database file if not exists')
  it('applies migrations in order')
  it('sets WAL journal mode')
  it('enables foreign keys')
  it('sets busy_timeout=5000')
  it('sets synchronous=NORMAL')
  it('sets temp_store=MEMORY')
  it('tracks schema version')
})

// tests/integration/app-lock.test.ts
describe('App Lock', () => {
  it('acquires lock successfully')
  it('updates heartbeat during operation')
  it('detects stale lock and recovers')
  it('refuses concurrent acquisition')
  it('allows force override')
  it('releases lock on completion')
})
```

GREEN: Implement db.ts, lock.ts, 001_initial.sql

VERIFY:
```bash
npm run test -- tests/unit/db.test.ts
npm run test -- tests/integration/app-lock.test.ts
```

## Edge Cases

- Migration file with syntax error should rollback and throw MigrationError
- Lock heartbeat timer must be unref()'d to not block process exit
- Database file permissions - fail clearly if not writable
- Concurrent lock tests need separate database files


[CP1] Final validation - tests, smoke tests, integrity checks

Run all tests and perform data integrity checks.

Validation steps:
1. Run all unit tests (vitest)
2. Run all integration tests
3. Run ESLint
4. Run TypeScript strict check
5. Manual smoke tests per PRD table
6. Data integrity SQL checks:
   - Issue count matches GitLab
   - Every issue has raw_payload
   - Labels in junction exist in labels table
   - sync_cursors has entry per project
   - Re-run fetches 0 new items
   - Discussion count > 0
   - Every discussion has >= 1 note
   - individual_note=true has exactly 1 note

Files: All CP1 files
Done when: All gate criteria from Definition of Done pass


Add GitLab MR types to types.rs

## Background
GitLab API types for merge requests. These structs define how we deserialize GitLab API responses. Must handle deprecated field aliases for backward compatibility with older GitLab instances.

## Approach
Add new structs to `src/gitlab/types.rs`:
- `GitLabMergeRequest` - Main MR struct with all fields
- `GitLabReviewer` - Reviewer with optional approval state
- `GitLabReferences` - Short and full reference strings

Use serde `#[serde(alias = "...")]` for deprecated field fallbacks.

## Files
- `src/gitlab/types.rs` - Add new structs after existing GitLabIssue
- `tests/fixtures/gitlab_merge_request.json` - Test fixture

## Acceptance Criteria
- [ ] `GitLabMergeRequest` struct exists with all fields from PRD
- [ ] `detailed_merge_status` field exists (non-deprecated)
- [ ] `#[serde(alias = "merge_status")]` on `merge_status_legacy` for fallback
- [ ] `merge_user` field exists (non-deprecated)
- [ ] `merged_by` field exists for fallback
- [ ] `draft` and `work_in_progress` both exist (draft preferred, WIP fallback)
- [ ] `sha` field maps to `head_sha` in transformer
- [ ] `references: Option<GitLabReferences>` for short/full refs
- [ ] `state: String` supports "opened", "merged", "closed", "locked"
- [ ] Fixture deserializes without error
- [ ] `cargo test` passes

## TDD Loop
RED: Add test that deserializes fixture -> struct not found
GREEN: Add GitLabMergeRequest, GitLabReviewer, GitLabReferences structs
VERIFY: `cargo test gitlab_types`

## Struct Definitions (from PRD)
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabMergeRequest {
    pub id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,                // "opened" | "merged" | "closed" | "locked"
    #[serde(default)]
    pub draft: bool,
    #[serde(default)]
    pub work_in_progress: bool,       // Deprecated fallback
    pub source_branch: String,
    pub target_branch: String,
    pub sha: Option<String>,          // head_sha
    pub references: Option<GitLabReferences>,
    pub detailed_merge_status: Option<String>,
    #[serde(alias = "merge_status")]
    pub merge_status_legacy: Option<String>,
    pub created_at: String,
    pub updated_at: String,
    pub merged_at: Option<String>,
    pub closed_at: Option<String>,
    pub author: GitLabAuthor,
    pub merge_user: Option<GitLabAuthor>,
    pub merged_by: Option<GitLabAuthor>,
    #[serde(default)]
    pub labels: Vec<String>,
    #[serde(default)]
    pub assignees: Vec<GitLabAuthor>,
    #[serde(default)]
    pub reviewers: Vec<GitLabReviewer>,
    pub web_url: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct GitLabReferences {
    pub short: String,   // e.g. "\!123"
    pub full: String,    // e.g. "group/project\!123"
}

#[derive(Debug, Clone, Deserialize)]
pub struct GitLabReviewer {
    pub id: i64,
    pub username: String,
    pub name: String,
}
```

## Test Fixture (create tests/fixtures/gitlab_merge_request.json)
```json
{
  "id": 12345,
  "iid": 42,
  "project_id": 100,
  "title": "Add user authentication",
  "description": "Implements JWT auth flow",
  "state": "merged",
  "draft": false,
  "work_in_progress": false,
  "source_branch": "feature/auth",
  "target_branch": "main",
  "sha": "abc123def456",
  "references": { "short": "\!42", "full": "group/project\!42" },
  "detailed_merge_status": "mergeable",
  "merge_status": "can_be_merged",
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-20T14:30:00Z",
  "merged_at": "2024-01-20T14:30:00Z",
  "closed_at": null,
  "author": { "id": 1, "username": "johndoe", "name": "John Doe" },
  "merge_user": { "id": 2, "username": "janedoe", "name": "Jane Doe" },
  "merged_by": { "id": 2, "username": "janedoe", "name": "Jane Doe" },
  "labels": ["enhancement", "auth"],
  "assignees": [{ "id": 3, "username": "bob", "name": "Bob Smith" }],
  "reviewers": [{ "id": 4, "username": "alice", "name": "Alice Wong" }],
  "web_url": "https://gitlab.example.com/group/project/-/merge_requests/42"
}
```

## Edge Cases
- `locked` state is transitional (merge in progress) - rare but valid
- Some older instances may not return `detailed_merge_status`
- Some older instances may not return `merge_user` (use `merged_by` fallback)
- `work_in_progress` is deprecated but still returned by some instances

Added GitLabMergeRequest, GitLabReviewer, GitLabReferences structs. Updated GitLabNotePosition with position_type, line_range, and SHA triplet fields. All 23 type tests passing.


[CP1] Issue ingestion module

Fetch and store issues with cursor-based incremental sync.

## Module
src/ingestion/issues.rs

## Key Structs

### IngestIssuesResult
- fetched: usize
- upserted: usize
- labels_created: usize
- issues_needing_discussion_sync: Vec<IssueForDiscussionSync>

### IssueForDiscussionSync
- local_issue_id: i64
- iid: i64
- updated_at: i64

## Main Function
pub async fn ingest_issues(conn, client, config, project_id, gitlab_project_id) -> Result<IngestIssuesResult>

## Logic
1. Get current cursor from sync_cursors (updated_at_cursor, tie_breaker_id)
2. Paginate through issues updated after cursor with cursor_rewind_seconds
3. Apply local filtering for tuple cursor semantics:
   - Skip if issue.updated_at < cursor_updated_at
   - Skip if issue.updated_at == cursor_updated_at AND issue.id <= cursor_gitlab_id
4. For each issue passing filter:
   - Begin transaction
   - Store raw payload (compressed)
   - Transform and upsert issue
   - Clear existing label links (DELETE FROM issue_labels)
   - Extract and upsert labels
   - Link issue to labels via junction
   - Commit transaction
   - Track for discussion sync eligibility
5. Incremental cursor update every 100 issues
6. Final cursor update
7. Determine issues needing discussion sync: where updated_at > discussions_synced_for_updated_at

## Helper Functions
- get_cursor(conn, project_id) -> (Option<i64>, Option<i64>)
- get_discussions_synced_at(conn, issue_id) -> Option<i64>
- upsert_issue(conn, issue, payload_id) -> usize
- get_local_issue_id(conn, gitlab_id) -> i64
- clear_issue_labels(conn, issue_id)
- upsert_label(conn, label) -> bool
- get_label_id(conn, project_id, name) -> i64
- link_issue_label(conn, issue_id, label_id)
- update_cursor(conn, project_id, resource_type, updated_at, gitlab_id)

Files: src/ingestion/mod.rs, src/ingestion/issues.rs
Tests: tests/issue_ingestion_tests.rs
Done when: Issues, labels, issue_labels populated correctly with resumable cursor


[CP1] gi sync-status enhancement

Enhance sync-status from CP0 stub to show issue cursors.

## Changes to src/cli/commands/sync_status.rs

Update the existing stub to show:
- Last run timestamp and duration
- Cursor positions per project (issues resource_type)
- Entity counts (issues, discussions, notes)

## Output Format
Last sync: 2026-01-25 10:30:00 (succeeded, 45s)

Cursors:
  group/project-one
    issues: 2026-01-25T10:25:00Z (gitlab_id: 12345678)

Counts:
  Issues: 1,234
  Discussions: 5,678
  Notes: 23,456 (4,567 system)

Files: src/cli/commands/sync_status.rs
Done when: Shows cursor positions and counts after ingestion


[CP1] Cargo.toml updates - async-stream and futures

Add required dependencies for async pagination streams.

## Changes
Add to Cargo.toml:
- async-stream = "0.3"
- futures = "0.3"

## Why
The pagination methods use async generators which require async-stream crate.
futures crate provides StreamExt for consuming the streams.

## Done When
- cargo check passes with new deps
- No unused dependency warnings

Files: Cargo.toml


[CP1] Integration tests for label linkage

Integration tests verifying label linkage and stale removal.

## Tests (tests/label_linkage_tests.rs)

- clears_existing_labels_before_linking_new_set
- removes_stale_label_links_on_issue_update
- handles_issue_with_all_labels_removed
- preserves_labels_that_still_exist

## Test Scenario
1. Create issue with labels [A, B]
2. Verify issue_labels has links to A and B
3. Update issue with labels [B, C]
4. Verify A link removed, B preserved, C added

## Why This Matters
The clear-and-relink pattern ensures GitLab reality is reflected locally.
If we only INSERT, removed labels would persist incorrectly.

Files: tests/label_linkage_tests.rs
Done when: Stale label links correctly removed on resync


[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


Add GitLab client MR pagination methods

## Background
GitLab client pagination for merge requests and discussions. Must support robust pagination with fallback chain because some GitLab instances/proxies strip headers.

## Approach
Add to existing `src/gitlab/client.rs`:
1. `MergeRequestPage` struct - Items + pagination metadata
2. `parse_link_header_next()` - RFC 8288 Link header parsing
3. `fetch_merge_requests_page()` - Single page fetch with metadata
4. `paginate_merge_requests()` - Async stream for all MRs
5. `paginate_mr_discussions()` - Async stream for MR discussions

## Files
- `src/gitlab/client.rs` - Add pagination methods

## Acceptance Criteria
- [ ] `MergeRequestPage` struct exists with `items`, `next_page`, `is_last_page`
- [ ] `parse_link_header_next()` extracts `rel="next"` URL from Link header
- [ ] Pagination fallback chain: Link header > x-next-page > full-page heuristic
- [ ] `paginate_merge_requests()` returns `Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>>>>`
- [ ] `paginate_mr_discussions()` returns `Pin<Box<dyn Stream<Item = Result<GitLabDiscussion>>>>`
- [ ] MR endpoint uses `scope=all&state=all` to include all MRs
- [ ] `cargo test client` passes

## TDD Loop
RED: `cargo test fetch_merge_requests` -> method not found
GREEN: Add pagination methods
VERIFY: `cargo test client`

## Struct Definitions
```rust
#[derive(Debug)]
pub struct MergeRequestPage {
    pub items: Vec<GitLabMergeRequest>,
    pub next_page: Option<u32>,
    pub is_last_page: bool,
}
```

## Link Header Parsing (RFC 8288)
```rust
/// Parse Link header to extract rel="next" URL.
fn parse_link_header_next(headers: &reqwest::header::HeaderMap) -> Option<String> {
    headers
        .get("link")
        .and_then(|v| v.to_str().ok())
        .and_then(|link_str| {
            // Format: <url>; rel="next", <url>; rel="last"
            for part in link_str.split(',') {
                let part = part.trim();
                if part.contains("rel=\"next\"") || part.contains("rel=next") {
                    if let Some(start) = part.find('<') {
                        if let Some(end) = part.find('>') {
                            return Some(part[start + 1..end].to_string());
                        }
                    }
                }
            }
            None
        })
}
```

## Pagination Fallback Chain
```rust
let next_page = match (link_next, x_next_page, items.len() as u32 == per_page) {
    (Some(_), _, _) => Some(page + 1),     // Link header present: continue
    (None, Some(np), _) => Some(np),       // x-next-page present: use it
    (None, None, true) => Some(page + 1),  // Full page, no headers: try next
    (None, None, false) => None,           // Partial page: we're done
};
```

## Fetch Single Page
```rust
pub async fn fetch_merge_requests_page(
    &self,
    gitlab_project_id: i64,
    updated_after: Option<i64>,
    cursor_rewind_seconds: u32,
    page: u32,
    per_page: u32,
) -> Result<MergeRequestPage> {
    let mut params = vec![
        ("scope", "all".to_string()),
        ("state", "all".to_string()),
        ("order_by", "updated_at".to_string()),
        ("sort", "asc".to_string()),
        ("per_page", per_page.to_string()),
        ("page", page.to_string()),
    ];
    // Apply cursor rewind for safety
    // ...
}
```

## Async Stream Pattern
```rust
pub fn paginate_merge_requests(
    &self,
    gitlab_project_id: i64,
    updated_after: Option<i64>,
    cursor_rewind_seconds: u32,
) -> Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>> + Send + '_>> {
    Box::pin(async_stream::try_stream! {
        let mut page = 1u32;
        let per_page = 100u32;
        loop {
            let page_result = self.fetch_merge_requests_page(...).await?;
            for mr in page_result.items {
                yield mr;
            }
            if page_result.is_last_page {
                break;
            }
            match page_result.next_page {
                Some(np) => page = np,
                None => break,
            }
        }
    })
}
```

## Edge Cases
- `scope=all` required to include all MRs (not just authored by current user)
- `state=all` required to include merged/closed (GitLab defaults may exclude)
- `locked` state cannot be filtered server-side (use local SQL filtering)
- Cursor rewind should clamp to 0 to avoid negative timestamps


[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


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


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