Files
gitlore/plans/gitlab-todos-notifications-integration.md
teernisse fd0a40b181 chore: update beads and GitLab TODOs integration plan
Update beads issue tracking state and expand the GitLab TODOs
notifications integration design document with additional
implementation details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 11:07:04 -05:00

18 KiB

plan, title, status, iteration, target_iterations, beads_revision, related_plans, created, updated, audit_revision
plan title status iteration target_iterations beads_revision related_plans created updated audit_revision
true GitLab TODOs Integration proposed 4 4 1
2026-02-23 2026-02-26 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:

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:

#[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:

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:

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:

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

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:

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

Autocorrect aliases in src/cli/mod.rs:

("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:

{
  "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:

// 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):

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:

[
  {
    "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