---
plan: true
title: "GitLab TODOs Integration"
status: proposed
iteration: 4
target_iterations: 4
beads_revision: 1
related_plans: []
created: 2026-02-23
updated: 2026-02-26
audit_revision: 4
---

# GitLab TODOs Integration

## Summary

Add GitLab TODO support to lore. Todos are fetched during sync, stored locally, and surfaced through a standalone `lore todos` command and integration into the `lore me` dashboard.

**Scope:** Read-only. No mark-as-done operations.

---

## Workflows

### Workflow 1: Morning Triage (Human)

1. User runs `lore me` to see personal dashboard
2. Summary header shows "5 pending todos" alongside issue/MR counts
3. Todos section groups items: 2 Assignments, 2 Mentions, 1 Approval Required
4. User scans Assignments — sees issue #42 assigned by @manager
5. User runs `lore todos` for full detail with body snippets
6. User clicks target URL to address highest-priority item
7. After marking done in GitLab, next `lore sync` removes it locally

### Workflow 2: Agent Polling (Robot Mode)

1. Agent runs `lore --robot health` as pre-flight check
2. Agent runs `lore --robot me --fields minimal` for dashboard
3. Agent extracts `pending_todo_count` from summary — if 0, skip todos
4. If count > 0, agent runs `lore --robot todos`
5. Agent iterates `data.todos[]`, filtering by `action` type
6. Agent prioritizes `approval_required` and `build_failed` for immediate attention
7. Agent logs external todos (`is_external: true`) for manual review

### Workflow 3: Cross-Project Visibility

1. User is mentioned in a project they don't sync (e.g., company-wide repo)
2. `lore sync` fetches the todo anyway (account-wide fetch)
3. `lore todos` shows item with `[external]` indicator and project path
4. User can still click target URL to view in GitLab
5. Target title may be unavailable — graceful fallback to "Untitled"

---

## Acceptance Criteria

Behavioral contract. Each AC is a single testable statement.

### Storage

| ID | Behavior |
|----|----------|
| AC-1 | Todos are persisted locally in SQLite |
| AC-2 | Each todo is uniquely identified by its GitLab todo ID |
| AC-3 | Todos from non-synced projects are stored with their project path |

### Sync

| ID | Behavior |
|----|----------|
| AC-4 | `lore sync` fetches all pending todos from GitLab |
| AC-5 | Sync fetches todos account-wide, not per-project |
| AC-6 | Todos marked done in GitLab are removed locally on next sync |
| AC-7 | Transient sync errors do not delete valid local todos |
| AC-8 | `lore sync --no-todos` skips todo fetching |
| AC-9 | Sync logs todo statistics (fetched, inserted, updated, deleted) |

### `lore todos` Command

| ID | Behavior |
|----|----------|
| AC-10 | `lore todos` displays all pending todos |
| AC-11 | Todos are grouped by action type: Assignments, Mentions, Approvals, Build Issues |
| AC-12 | Each todo shows: target title, project path, author, age |
| AC-13 | Non-synced project todos display `[external]` indicator |
| AC-14 | `lore todos --limit N` limits output to N todos |
| AC-15 | `lore --robot todos` returns JSON with standard `{ok, data, meta}` envelope |
| AC-16 | `lore --robot todos --fields minimal` returns reduced field set |
| AC-17 | `todo` and `td` are recognized as aliases for `todos` |

### `lore me` Integration

| ID | Behavior |
|----|----------|
| AC-18 | `lore me` summary includes pending todo count |
| AC-19 | `lore me` includes a todos section in the full dashboard |
| AC-20 | `lore me --todos` shows only the todos section |
| AC-21 | Todos are NOT filtered by `--project` flag (always account-wide) |
| AC-22 | Warning is displayed if `--project` is passed with `--todos` |
| AC-23 | Todo events appear in the activity feed for local entities |

### Action Types

| ID | Behavior |
|----|----------|
| AC-24 | Core actions are displayed: assigned, mentioned, directly_addressed, approval_required, build_failed, unmergeable |
| AC-25 | Niche actions are stored but not displayed: merge_train_removed, member_access_requested, marked |

### Attention State

| ID | Behavior |
|----|----------|
| AC-26 | Todos do not affect attention state calculation |
| AC-27 | Todos do not appear in "since last check" cursor-based inbox |

### Error Handling

| ID | Behavior |
|----|----------|
| AC-28 | 403 Forbidden on todos API logs warning and continues sync |
| AC-29 | 429 Rate Limited respects Retry-After header |
| AC-30 | Malformed todo JSON logs warning, skips that item, and disables purge for that sync |

### Documentation

| ID | Behavior |
|----|----------|
| AC-31 | `lore todos` appears in CLI help |
| AC-32 | `lore robot-docs` includes todos schema |
| AC-33 | CLAUDE.md documents the todos command |

### Quality

| ID | Behavior |
|----|----------|
| AC-34 | All quality gates pass: check, clippy, fmt, test |

---

## Architecture

Designed to fulfill the acceptance criteria above.

### Module Structure

```
src/
├── gitlab/
│   ├── client.rs      # fetch_todos() method (AC-4, AC-5)
│   └── types.rs       # GitLabTodo struct
├── ingestion/
│   └── todos.rs       # sync_todos(), purge-safe deletion (AC-6, AC-7)
├── cli/commands/
│   ├── todos.rs       # lore todos command (AC-10-17)
│   └── me/
│       ├── types.rs   # MeTodo, extend MeSummary (AC-18)
│       └── queries.rs # query_todos() (AC-19, AC-23)
└── core/
    └── db.rs          # Migration 028 (AC-1, AC-2, AC-3)
```

### Data Flow

```
GitLab API                Local SQLite              CLI Output
───────────               ────────────              ──────────
GET /api/v4/todos    →    todos table          →   lore todos
(account-wide)            (purge-safe sync)        lore me --todos
```

### Key Design Decisions

| Decision | Rationale | ACs |
|----------|-----------|-----|
| Account-wide fetch | GitLab todos API is user-scoped, not project-scoped | AC-5, AC-21 |
| Purge-safe deletion | Transient errors should not delete valid data | AC-7 |
| Separate from attention | Todos are notifications, not engagement signals | AC-26, AC-27 |
| Store all actions, display core | Future-proofs for new action types | AC-24, AC-25 |

### Existing Code to Extend

| Type | Location | Extension |
|------|----------|-----------|
| `MeSummary` | `src/cli/commands/me/types.rs` | Add `pending_todo_count` field |
| `ActivityEventType` | `src/cli/commands/me/types.rs` | Add `Todo` variant |
| `MeDashboard` | `src/cli/commands/me/types.rs` | Add `todos: Vec<MeTodo>` field |
| `SyncArgs` | `src/cli/mod.rs` | Add `--no-todos` flag |
| `MeArgs` | `src/cli/mod.rs` | Add `--todos` flag |

---

## Implementation Specifications

Each IMP section details HOW to fulfill specific ACs.

### IMP-1: Database Schema

**Fulfills:** AC-1, AC-2, AC-3

**Migration 028:**

```sql
CREATE TABLE todos (
    id INTEGER PRIMARY KEY,
    gitlab_todo_id INTEGER NOT NULL UNIQUE,
    project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
    gitlab_project_id INTEGER,
    target_type TEXT NOT NULL,
    target_id TEXT,
    target_iid INTEGER,
    target_url TEXT NOT NULL,
    target_title TEXT,
    action_name TEXT NOT NULL,
    author_id INTEGER,
    author_username TEXT,
    body TEXT,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL,
    synced_at INTEGER NOT NULL,
    sync_generation INTEGER NOT NULL DEFAULT 0,
    project_path TEXT
);

CREATE INDEX idx_todos_action_created ON todos(action_name, created_at DESC);
CREATE INDEX idx_todos_target ON todos(target_type, target_id);
CREATE INDEX idx_todos_created ON todos(created_at DESC);
CREATE INDEX idx_todos_sync_gen ON todos(sync_generation);
CREATE INDEX idx_todos_gitlab_project ON todos(gitlab_project_id);
CREATE INDEX idx_todos_target_lookup ON todos(target_type, project_id, target_iid);
```

**Notes:**
- `project_id` nullable for non-synced projects (AC-3)
- `gitlab_project_id` nullable — TODO targets include non-project entities (Namespace, etc.)
- No `state` column — we only store pending todos
- `sync_generation` enables two-generation grace purge (AC-7)

---

### IMP-2: GitLab API Client

**Fulfills:** AC-4, AC-5

**Endpoint:** `GET /api/v4/todos?state=pending`

**Types to add in `src/gitlab/types.rs`:**

```rust
#[derive(Debug, Deserialize)]
pub struct GitLabTodo {
    pub id: i64,
    pub project: Option<GitLabTodoProject>,
    pub author: Option<GitLabTodoAuthor>,
    pub action_name: String,
    pub target_type: String,
    pub target: Option<GitLabTodoTarget>,
    pub target_url: String,
    pub body: Option<String>,
    pub state: String,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Debug, Deserialize)]
pub struct GitLabTodoProject {
    pub id: i64,
    pub path_with_namespace: String,
}

#[derive(Debug, Deserialize)]
pub struct GitLabTodoTarget {
    pub id: serde_json::Value,  // i64 or String (commit SHA)
    pub iid: Option<i64>,
    pub title: Option<String>,
}

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

**Client method in `src/gitlab/client.rs`:**

```rust
pub fn fetch_todos(&self) -> impl Stream<Item = Result<GitLabTodo>> {
    self.paginate("/api/v4/todos?state=pending")
}
```

---

### IMP-3: Sync Pipeline Integration

**Fulfills:** AC-4, AC-5, AC-6, AC-7, AC-8, AC-9

**New file: `src/ingestion/todos.rs`**

**Sync position:** Account-wide step after per-project sync and status enrichment.

```
Sync order:
1. Issues (per project)
2. MRs (per project)
3. Status enrichment (account-wide GraphQL)
4. Todos (account-wide REST) ← NEW
```

**Purge-safe deletion pattern:**

```rust
pub struct TodoSyncResult {
    pub fetched: usize,
    pub upserted: usize,
    pub deleted: usize,
    pub generation: i64,
    pub purge_allowed: bool,
}

pub fn sync_todos(conn: &Connection, client: &GitLabClient) -> Result<TodoSyncResult> {
    // 1. Get next generation
    let generation: i64 = conn.query_row(
        "SELECT COALESCE(MAX(sync_generation), 0) + 1 FROM todos",
        [], |r| r.get(0)
    )?;

    let mut fetched = 0;
    let mut purge_allowed = true;

    // 2. Fetch and upsert all todos
    for result in client.fetch_todos()? {
        match result {
            Ok(todo) => {
                upsert_todo_guarded(conn, &todo, generation)?;
                fetched += 1;
            }
            Err(e) => {
                // Malformed JSON: log warning, skip item, disable purge
                warn!("Skipping malformed todo: {e}");
                purge_allowed = false;
            }
        }
    }

    // 3. Two-generation grace purge: delete only if missing for 2+ consecutive syncs
    // This protects against pagination drift (new todos inserted during traversal)
    let deleted = if purge_allowed {
        conn.execute("DELETE FROM todos WHERE sync_generation < ? - 1", [generation])?
    } else {
        0
    };

    Ok(TodoSyncResult { fetched, upserted: fetched, deleted, generation, purge_allowed })
}
```

**Concurrent-safe upsert:**

```sql
INSERT INTO todos (..., sync_generation) VALUES (?, ..., ?)
ON CONFLICT(gitlab_todo_id) DO UPDATE SET
    ...,
    sync_generation = excluded.sync_generation,
    synced_at = excluded.synced_at
WHERE excluded.sync_generation >= todos.sync_generation;
```

**"Success" for purge (all must be true):**
- Every page fetch completed without error
- Every todo JSON decoded successfully (any decode failure sets `purge_allowed=false`)
- Pagination traversal completed (not interrupted)
- Response was not 401/403
- Zero todos IS valid for purge when above conditions met

**Two-generation grace purge:**
Todos are deleted only if missing for 2 consecutive successful syncs (`sync_generation < current - 1`).
This protects against false deletions from pagination drift (new todos inserted during traversal).

---

### IMP-4: Project Path Extraction

**Fulfills:** AC-3, AC-13

```rust
use once_cell::sync::Lazy;
use regex::Regex;

pub fn extract_project_path(url: &str) -> Option<&str> {
    static RE: Lazy<Regex> = Lazy::new(|| {
        Regex::new(r"https?://[^/]+/(.+?)/-/(?:issues|merge_requests|epics|commits)/")
            .expect("valid regex")
    });

    RE.captures(url)
        .and_then(|c| c.get(1))
        .map(|m| m.as_str())
}
```

**Usage:** Prefer `project.path_with_namespace` from API when available. Fall back to URL extraction for external projects.

---

### IMP-5: `lore todos` Command

**Fulfills:** AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17

**New file: `src/cli/commands/todos.rs`**

**Args:**

```rust
#[derive(Parser)]
#[command(alias = "todo")]
pub struct TodosArgs {
    #[arg(short = 'n', long)]
    pub limit: Option<usize>,
}
```

**Autocorrect aliases in `src/cli/mod.rs`:**

```rust
("td", "todos"),
("todo", "todos"),
```

**Action type grouping:**

| Group | Actions |
|-------|---------|
| Assignments | `assigned` |
| Mentions | `mentioned`, `directly_addressed` |
| Approvals | `approval_required` |
| Build Issues | `build_failed`, `unmergeable` |

**Robot mode schema:**

```json
{
  "ok": true,
  "data": {
    "todos": [{
      "id": 123,
      "gitlab_todo_id": 456,
      "action": "mentioned",
      "target_type": "Issue",
      "target_iid": 42,
      "target_title": "Fix login bug",
      "target_url": "https://...",
      "project_path": "group/repo",
      "author_username": "jdoe",
      "body": "Hey @you, can you look at this?",
      "created_at_iso": "2026-02-20T10:00:00Z",
      "is_external": false
    }],
    "counts": {
      "total": 8,
      "assigned": 2,
      "mentioned": 5,
      "approval_required": 1,
      "build_failed": 0,
      "unmergeable": 0,
      "other": 0
    }
  },
  "meta": {"elapsed_ms": 42}
}
```

**Minimal fields:** `gitlab_todo_id`, `action`, `target_type`, `target_iid`, `project_path`, `is_external`

---

### IMP-6: `lore me` Integration

**Fulfills:** AC-18, AC-19, AC-20, AC-21, AC-22, AC-23

**Types to add/extend in `src/cli/commands/me/types.rs`:**

```rust
// EXTEND
pub struct MeSummary {
    // ... existing fields ...
    pub pending_todo_count: usize,  // ADD
}

// EXTEND
pub enum ActivityEventType {
    // ... existing variants ...
    Todo,  // ADD
}

// EXTEND
pub struct MeDashboard {
    // ... existing fields ...
    pub todos: Vec<MeTodo>,  // ADD
}

// NEW
pub struct MeTodo {
    pub id: i64,
    pub gitlab_todo_id: i64,
    pub action: String,
    pub target_type: String,
    pub target_iid: Option<i64>,
    pub target_title: Option<String>,
    pub target_url: String,
    pub project_path: String,
    pub author_username: Option<String>,
    pub body: Option<String>,
    pub created_at: i64,
    pub is_external: bool,
}
```

**Warning for `--project` with `--todos` (AC-22):**

```rust
if args.todos && args.project.is_some() {
    eprintln!("Warning: Todos are account-wide; project filter not applied");
}
```

---

### IMP-7: Error Handling

**Fulfills:** AC-28, AC-29, AC-30

| Error | Behavior |
|-------|----------|
| 403 Forbidden | Log warning, skip todo sync, continue with other entities |
| 429 Rate Limited | Respect `Retry-After` header using existing retry policy |
| Malformed JSON | Log warning with todo ID, skip item, set `purge_allowed=false`, continue batch |

**Rationale for purge disable on malformed JSON:** If we can't decode a todo, we don't know its `gitlab_todo_id`. Without that, we might accidentally purge a valid todo that was simply malformed in transit. Disabling purge for that sync is the safe choice.

---

### IMP-8: Test Fixtures

**Fulfills:** AC-34

**Location:** `tests/fixtures/todos/`

**`todos_pending.json`:**
```json
[
  {
    "id": 102,
    "project": {"id": 2, "path_with_namespace": "diaspora/client"},
    "author": {"id": 1, "username": "admin"},
    "action_name": "mentioned",
    "target_type": "Issue",
    "target": {"id": 11, "iid": 4, "title": "Inventory system"},
    "target_url": "https://gitlab.example.com/diaspora/client/-/issues/4",
    "body": "@user please review",
    "state": "pending",
    "created_at": "2026-02-20T10:00:00.000Z",
    "updated_at": "2026-02-20T10:00:00.000Z"
  }
]
```

**`todos_empty.json`:** `[]`

**`todos_commit_target.json`:** (target.id is string SHA)

**`todos_niche_actions.json`:** (merge_train_removed, etc.)

---

## Rollout Slices

### Dependency Graph

```
Slice A ──────► Slice B ──────┬──────► Slice C
(Schema)        (Sync)        │        (`lore todos`)
                              │
                              └──────► Slice D
                                       (`lore me`)

                 Slice C ───┬───► Slice E
                 Slice D ───┘    (Polish)
```

### Slice A: Schema + Client

**ACs:** AC-1, AC-2, AC-3, AC-4, AC-5
**IMPs:** IMP-1, IMP-2, IMP-4
**Deliverable:** Migration + client method + deserialization tests pass

### Slice B: Sync Integration

**ACs:** AC-6, AC-7, AC-8, AC-9, AC-28, AC-29, AC-30
**IMPs:** IMP-3, IMP-7
**Deliverable:** `lore sync` fetches todos; `--no-todos` works

### Slice C: `lore todos` Command

**ACs:** AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-24, AC-25
**IMPs:** IMP-5
**Deliverable:** `lore todos` and `lore --robot todos` work

### Slice D: `lore me` Integration

**ACs:** AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-26, AC-27
**IMPs:** IMP-6
**Deliverable:** `lore me --todos` works; summary shows count

### Slice E: Polish

**ACs:** AC-31, AC-32, AC-33, AC-34
**IMPs:** IMP-8
**Deliverable:** Docs updated; all quality gates pass

---

## Design Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Write operations | Read-only | Complexity; glab handles writes |
| Storage | SQLite | Consistent with existing architecture |
| Project filter | Account-wide only | GitLab API is user-scoped |
| Action type display | Core only | Reduce noise; store all for future |
| Attention state | Separate signal | Todos are notifications, not engagement |
| History | Pending only | Simplicity; done todos have no value locally |
| Grouping | By action type | Matches GitLab UI; aids triage |
| Purge strategy | Two-generation grace | Protects against pagination drift during sync |

---

## Out of Scope

- Write operations (mark as done)
- Done todo history tracking
- Filters beyond `--limit`
- Todo-based attention state boosting
- Notification settings API

---

## References

- [GitLab To-Do List API](https://docs.gitlab.com/api/todos/)
- [GitLab User Todos](https://docs.gitlab.com/user/todos/)
