--- 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` 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, pub author: Option, pub action_name: String, pub target_type: String, pub target: Option, pub target_url: String, pub body: Option, 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, pub title: Option, } #[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> { 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 { // 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 = 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, } ``` **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, // ADD } // NEW pub struct MeTodo { pub id: i64, pub gitlab_todo_id: i64, pub action: String, pub target_type: String, pub target_iid: Option, pub target_title: Option, pub target_url: String, pub project_path: String, pub author_username: Option, pub body: Option, 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/)