Files
gitlore/docs/plan-expose-discussion-ids.md

32 KiB

Plan: Expose Discussion IDs Across the Read Surface

Problem: Agents can't bridge from lore's read output to glab's write API because gitlab_discussion_id — stored in the DB — is never exposed in any output. The read/write split contract requires lore to emit every identifier an agent needs to construct a glab write command.

Scope: Three changes, delivered in order:

  1. Add gitlab_discussion_id to notes output
  2. Add gitlab_discussion_id to show command discussion groups
  3. Add a standalone discussions list command
  4. Fix robot-docs to list actual field names instead of opaque type references

1. Add gitlab_discussion_id to Notes Output

Why

The notes command joins discussions but never SELECTs d.gitlab_discussion_id. An agent that finds a note via --contains or --for-mr has no way to get the discussion thread ID needed to reply via glab api.

Current Code

SQL query (src/cli/commands/list.rs:1366-1395):

SELECT
    n.id, n.gitlab_id, n.author_username, n.body, n.note_type,
    n.is_system, n.created_at, n.updated_at,
    n.position_new_path, n.position_new_line,
    n.position_old_path, n.position_old_line,
    n.resolvable, n.resolved, n.resolved_by,
    d.noteable_type,
    COALESCE(i.iid, m.iid) AS parent_iid,
    COALESCE(i.title, m.title) AS parent_title,
    p.path_with_namespace AS project_path
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN projects p ON n.project_id = p.id
LEFT JOIN issues i ON d.issue_id = i.id
LEFT JOIN merge_requests m ON d.merge_request_id = m.id

Row struct (src/cli/commands/list.rs:1041-1061):

pub struct NoteListRow {
    pub id: i64,
    pub gitlab_id: i64,
    pub author_username: String,
    pub body: Option<String>,
    pub note_type: Option<String>,
    pub is_system: bool,
    pub created_at: i64,
    pub updated_at: i64,
    pub position_new_path: Option<String>,
    pub position_new_line: Option<i64>,
    pub position_old_path: Option<String>,
    pub position_old_line: Option<i64>,
    pub resolvable: bool,
    pub resolved: bool,
    pub resolved_by: Option<String>,
    pub noteable_type: Option<String>,
    pub parent_iid: Option<i64>,
    pub parent_title: Option<String>,
    pub project_path: String,
}

JSON struct (src/cli/commands/list.rs:1063-1094):

pub struct NoteListRowJson {
    pub id: i64,
    pub gitlab_id: i64,
    pub author_username: String,
    // ... (same fields, no gitlab_discussion_id)
    pub project_path: String,
}

Changes Required

1a. Add field to SQL SELECT

File: src/cli/commands/list.rs line ~1386

Add d.gitlab_discussion_id to the SELECT list. Insert it after p.path_with_namespace AS project_path since it's from the already-joined discussions table.

-- ADD this line after project_path:
d.gitlab_discussion_id

Column index shifts: the new field is at index 19 (0-based).

1b. Add field to NoteListRow

File: src/cli/commands/list.rs line ~1060

pub struct NoteListRow {
    // ... existing fields ...
    pub project_path: String,
    pub gitlab_discussion_id: String,  // ADD
}

And in the query_map closure (line ~1407):

Ok(NoteListRow {
    // ... existing fields ...
    project_path: row.get(18)?,
    gitlab_discussion_id: row.get(19)?,  // ADD
})

1c. Add field to NoteListRowJson

File: src/cli/commands/list.rs line ~1093

pub struct NoteListRowJson {
    // ... existing fields ...
    pub project_path: String,
    pub gitlab_discussion_id: String,  // ADD
}

And in the From<&NoteListRow> impl (line ~1096):

impl From<&NoteListRow> for NoteListRowJson {
    fn from(row: &NoteListRow) -> Self {
        Self {
            // ... existing fields ...
            project_path: row.project_path.clone(),
            gitlab_discussion_id: row.gitlab_discussion_id.clone(),  // ADD
        }
    }
}

1d. Add to CSV header

File: src/cli/commands/list.rs line ~1004

Add gitlab_discussion_id to the CSV header and row output.

1e. Add to table display

File: src/cli/commands/list.rs, print_list_notes() function

Add a column showing a truncated discussion ID (first 8 chars) in the table view.

1f. Update --fields minimal preset

File: src/cli/robot.rs line ~67

"notes" => ["id", "author_username", "body", "created_at_iso", "gitlab_discussion_id"]
    .iter()
    .map(|s| (*s).to_string())
    .collect(),

The discussion ID is critical enough for agent workflows that it belongs in minimal.

Tests

File: src/cli/commands/list_tests.rs

Test 1: gitlab_discussion_id present in NoteListRowJson

#[test]
fn note_list_row_json_includes_gitlab_discussion_id() {
    let row = NoteListRow {
        id: 1,
        gitlab_id: 100,
        author_username: "alice".to_string(),
        body: Some("test".to_string()),
        note_type: Some("DiscussionNote".to_string()),
        is_system: false,
        created_at: 1_700_000_000_000,
        updated_at: 1_700_000_000_000,
        position_new_path: None,
        position_new_line: None,
        position_old_path: None,
        position_old_line: None,
        resolvable: false,
        resolved: false,
        resolved_by: None,
        noteable_type: Some("MergeRequest".to_string()),
        parent_iid: Some(42),
        parent_title: Some("Fix bug".to_string()),
        project_path: "group/project".to_string(),
        gitlab_discussion_id: "6a9c1750b37d".to_string(),
    };

    let json_row = NoteListRowJson::from(&row);
    assert_eq!(json_row.gitlab_discussion_id, "6a9c1750b37d");

    let serialized = serde_json::to_value(&json_row).unwrap();
    assert!(serialized.get("gitlab_discussion_id").is_some());
    assert_eq!(
        serialized["gitlab_discussion_id"].as_str().unwrap(),
        "6a9c1750b37d"
    );
}

Test 2: query_notes returns gitlab_discussion_id from DB

#[test]
fn query_notes_returns_gitlab_discussion_id() {
    let conn = create_connection(Path::new(":memory:")).unwrap();
    run_migrations(&conn).unwrap();

    // Insert project, discussion, note
    conn.execute(
        "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url)
         VALUES (1, 1, 'group/project', 'https://gitlab.com/group/project')",
        [],
    ).unwrap();
    conn.execute(
        "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
         VALUES (1, 'abc123def456', 1, NULL, 'MergeRequest', 1000)",
        [],
    ).unwrap();
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at)
         VALUES (1, 1, 1, 99, 'Test MR', 'opened', 'alice', 'feat', 'main', 1000, 1000, 1000)",
        [],
    ).unwrap();
    // Update discussion to reference MR
    conn.execute(
        "UPDATE discussions SET merge_request_id = 1 WHERE id = 1",
        [],
    ).unwrap();
    conn.execute(
        "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position)
         VALUES (1, 500, 1, 1, 'bob', 'test note', 1000, 1000, 1000, 0, 0)",
        [],
    ).unwrap();

    let config = Config::default();
    let filters = NoteListFilters {
        limit: 10,
        project: None,
        author: None,
        note_type: None,
        include_system: false,
        for_issue_iid: None,
        for_mr_iid: None,
        note_id: None,
        gitlab_note_id: None,
        discussion_id: None,
        since: None,
        until: None,
        path: None,
        contains: None,
        resolution: None,
        sort: "created".to_string(),
        order: "desc".to_string(),
    };

    let result = query_notes(&conn, &filters, &config).unwrap();
    assert_eq!(result.notes.len(), 1);
    assert_eq!(result.notes[0].gitlab_discussion_id, "abc123def456");
}

Test 3: --fields filtering includes gitlab_discussion_id

#[test]
fn fields_filter_retains_gitlab_discussion_id() {
    let mut value = serde_json::json!({
        "data": {
            "notes": [{
                "id": 1,
                "gitlab_id": 100,
                "author_username": "alice",
                "body": "test",
                "gitlab_discussion_id": "abc123"
            }]
        }
    });

    filter_fields(
        &mut value,
        "notes",
        &["id".to_string(), "gitlab_discussion_id".to_string()],
    );

    let note = &value["data"]["notes"][0];
    assert_eq!(note["id"], 1);
    assert_eq!(note["gitlab_discussion_id"], "abc123");
    assert!(note.get("body").is_none());
}

2. Add gitlab_discussion_id to Show Command Discussion Groups

Why

lore -J issues 42 and lore -J mrs 99 return discussions grouped by thread, but neither DiscussionDetailJson nor MrDiscussionDetailJson includes the gitlab_discussion_id. An agent viewing MR details can see all discussion threads but can't identify which one to reply to.

Current Code

Issue discussions (src/cli/commands/show.rs:99-102):

pub struct DiscussionDetail {
    pub notes: Vec<NoteDetail>,
    pub individual_note: bool,
}

MR discussions (src/cli/commands/show.rs:37-40):

pub struct MrDiscussionDetail {
    pub notes: Vec<MrNoteDetail>,
    pub individual_note: bool,
}

JSON equivalents (show.rs:1001-1003 and show.rs:1100-1103):

pub struct DiscussionDetailJson {
    pub notes: Vec<NoteDetailJson>,
    pub individual_note: bool,
}
pub struct MrDiscussionDetailJson {
    pub notes: Vec<MrNoteDetailJson>,
    pub individual_note: bool,
}

Queries (show.rs:325-328 and show.rs:537-540):

SELECT id, individual_note FROM discussions WHERE issue_id = ? ORDER BY first_note_at
SELECT id, individual_note FROM discussions WHERE merge_request_id = ? ORDER BY first_note_at

Changes Required

2a. Add field to domain structs

File: src/cli/commands/show.rs

pub struct DiscussionDetail {
    pub gitlab_discussion_id: String,  // ADD
    pub notes: Vec<NoteDetail>,
    pub individual_note: bool,
}

pub struct MrDiscussionDetail {
    pub gitlab_discussion_id: String,  // ADD
    pub notes: Vec<MrNoteDetail>,
    pub individual_note: bool,
}

2b. Add field to JSON structs

pub struct DiscussionDetailJson {
    pub gitlab_discussion_id: String,  // ADD
    pub notes: Vec<NoteDetailJson>,
    pub individual_note: bool,
}

pub struct MrDiscussionDetailJson {
    pub gitlab_discussion_id: String,  // ADD
    pub notes: Vec<MrNoteDetailJson>,
    pub individual_note: bool,
}

2c. Update queries to SELECT gitlab_discussion_id

Issue discussions (show.rs:325):

SELECT id, gitlab_discussion_id, individual_note FROM discussions
WHERE issue_id = ? ORDER BY first_note_at

MR discussions (show.rs:537):

SELECT id, gitlab_discussion_id, individual_note FROM discussions
WHERE merge_request_id = ? ORDER BY first_note_at

2d. Update query_map closures

The disc_rows tuple changes from (i64, bool) to (i64, String, bool).

Issue path (show.rs:331-335):

let disc_rows: Vec<(i64, String, bool)> = disc_stmt
    .query_map([issue_id], |row| {
        let individual: i64 = row.get(2)?;
        Ok((row.get(0)?, row.get(1)?, individual == 1))
    })?
    .collect::<std::result::Result<Vec<_>, _>>()?;

And where discussions are constructed (show.rs:361):

for (disc_id, gitlab_disc_id, individual_note) in disc_rows {
    // ... existing note query ...
    discussions.push(DiscussionDetail {
        gitlab_discussion_id: gitlab_disc_id,
        notes,
        individual_note,
    });
}

Same pattern for MR discussions (show.rs:543-560, show.rs:598).

2e. Update From impls

impl From<&DiscussionDetail> for DiscussionDetailJson {
    fn from(disc: &DiscussionDetail) -> Self {
        Self {
            gitlab_discussion_id: disc.gitlab_discussion_id.clone(),
            notes: disc.notes.iter().map(|n| n.into()).collect(),
            individual_note: disc.individual_note,
        }
    }
}

impl From<&MrDiscussionDetail> for MrDiscussionDetailJson {
    fn from(disc: &MrDiscussionDetail) -> Self {
        Self {
            gitlab_discussion_id: disc.gitlab_discussion_id.clone(),
            notes: disc.notes.iter().map(|n| n.into()).collect(),
            individual_note: disc.individual_note,
        }
    }
}

2f. Add gitlab_note_id to note detail structs in show

While we're here, add gitlab_id to NoteDetail, MrNoteDetail, and their JSON counterparts. Currently show-command notes only have author_username, body, created_at, is_system — no note ID at all, making it impossible to reference a specific note.

Tests

File: src/cli/commands/show_tests.rs (or within show.rs #[cfg(test)])

Test 1: Issue show includes gitlab_discussion_id

#[test]
fn show_issue_includes_gitlab_discussion_id() {
    // Setup: project, issue, discussion with known gitlab_discussion_id, note
    let conn = create_test_db();
    insert_project(&conn, 1);
    insert_issue(&conn, 1, 1, 42);
    conn.execute(
        "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
         VALUES (1, 'abc123hex', 1, 1, 'Issue', 1000)",
        [],
    ).unwrap();
    insert_note(&conn, 1, 500, 1, 1, "alice", "hello", false);

    let detail = run_show_issue_with_conn(&conn, 42, None).unwrap();
    assert_eq!(detail.discussions.len(), 1);
    assert_eq!(detail.discussions[0].gitlab_discussion_id, "abc123hex");
}

Test 2: MR show includes gitlab_discussion_id

Same pattern for MR path.

Test 3: JSON serialization includes the field

#[test]
fn discussion_detail_json_has_gitlab_discussion_id() {
    let detail = DiscussionDetail {
        gitlab_discussion_id: "deadbeef".to_string(),
        notes: vec![],
        individual_note: false,
    };
    let json = DiscussionDetailJson::from(&detail);
    let value = serde_json::to_value(&json).unwrap();
    assert_eq!(value["gitlab_discussion_id"], "deadbeef");
}

3. Add Standalone discussions List Command

Why

Discussions are a first-class entity in the DB (211K rows) but invisible to agents as a collection. An agent working on an MR needs to see all threads at a glance — which are unresolved, who started them, what file they're on — with the gitlab_discussion_id needed to reply. Currently the only way is lore notes --for-mr 99 which returns flat notes without discussion grouping.

Design

lore discussions [OPTIONS]

# List all discussions on MR 99 (most common agent use case)
lore -J discussions --for-mr 99

# List unresolved discussions on MR 99
lore -J discussions --for-mr 99 --resolution unresolved

# List discussions on issue 42
lore -J discussions --for-issue 42

# List discussions across a project
lore -J discussions -p group/repo --since 7d

Response Schema

{
  "ok": true,
  "data": {
    "discussions": [
      {
        "gitlab_discussion_id": "6a9c1750b37d513a...",
        "noteable_type": "MergeRequest",
        "parent_iid": 3929,
        "parent_title": "Resolve \"Switch Health Card\"",
        "project_path": "vs/typescript-code",
        "individual_note": false,
        "note_count": 3,
        "first_author": "elovegrove",
        "first_note_body_snippet": "Ok @teernisse well I really do prefer...",
        "first_note_at_iso": "2026-02-16T14:31:34Z",
        "last_note_at_iso": "2026-02-16T15:02:11Z",
        "resolvable": true,
        "resolved": false,
        "position_new_path": "src/components/SwitchHealthCard.vue",
        "position_new_line": 42
      }
    ],
    "total_count": 15,
    "showing": 15
  },
  "meta": { "elapsed_ms": 12 }
}

File Architecture

No new files. Follow the existing pattern:

  • Args struct: src/cli/mod.rs (alongside NotesArgs, IssuesArgs)
  • Query + print functions: src/cli/commands/list.rs (alongside query_notes, print_list_notes_json)
  • Handler: src/main.rs (alongside handle_notes)
  • Tests: src/cli/commands/list_tests.rs
  • Robot-docs: src/main.rs robot-docs JSON block

Implementation Details

3a. CLI Args

File: src/cli/mod.rs

Add variant to Commands enum (after Notes):

/// List discussions
#[command(visible_alias = "discussion")]
Discussions(DiscussionsArgs),

Args struct:

#[derive(Parser)]
pub struct DiscussionsArgs {
    /// Maximum results
    #[arg(short = 'n', long = "limit", default_value = "50", help_heading = "Output")]
    pub limit: usize,

    /// Select output fields (comma-separated, or 'minimal' preset)
    #[arg(long, help_heading = "Output", value_delimiter = ',')]
    pub fields: Option<Vec<String>>,

    /// Output format (table, json, jsonl, csv)
    #[arg(long, default_value = "table", value_parser = ["table", "json", "jsonl", "csv"], help_heading = "Output")]
    pub format: String,

    /// Filter to discussions on a specific issue IID
    #[arg(long, conflicts_with = "for_mr", help_heading = "Filters")]
    pub for_issue: Option<i64>,

    /// Filter to discussions on a specific MR IID
    #[arg(long, conflicts_with = "for_issue", help_heading = "Filters")]
    pub for_mr: Option<i64>,

    /// Filter by project path
    #[arg(short = 'p', long, help_heading = "Filters")]
    pub project: Option<String>,

    /// Filter by resolution status (unresolved, resolved)
    #[arg(long, value_parser = ["unresolved", "resolved"], help_heading = "Filters")]
    pub resolution: Option<String>,

    /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
    #[arg(long, help_heading = "Filters")]
    pub since: Option<String>,

    /// Filter by file path (exact match or prefix with trailing /)
    #[arg(long, help_heading = "Filters")]
    pub path: Option<String>,

    /// Filter by noteable type (Issue, MergeRequest)
    #[arg(long, value_parser = ["Issue", "MergeRequest"], help_heading = "Filters")]
    pub noteable_type: Option<String>,

    /// Sort field (first_note, last_note)
    #[arg(long, value_parser = ["first_note", "last_note"], default_value = "last_note", help_heading = "Sorting")]
    pub sort: String,

    /// Sort ascending (default: descending)
    #[arg(long, help_heading = "Sorting")]
    pub asc: bool,
}

3b. Domain Structs

File: src/cli/commands/list.rs

#[derive(Debug)]
pub struct DiscussionListRow {
    pub id: i64,
    pub gitlab_discussion_id: String,
    pub noteable_type: String,
    pub parent_iid: Option<i64>,
    pub parent_title: Option<String>,
    pub project_path: String,
    pub individual_note: bool,
    pub note_count: i64,
    pub first_author: Option<String>,
    pub first_note_body: Option<String>,
    pub first_note_at: i64,
    pub last_note_at: i64,
    pub resolvable: bool,
    pub resolved: bool,
    pub position_new_path: Option<String>,
    pub position_new_line: Option<i64>,
}

#[derive(Serialize)]
pub struct DiscussionListRowJson {
    pub gitlab_discussion_id: String,
    pub noteable_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_iid: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_title: Option<String>,
    pub project_path: String,
    pub individual_note: bool,
    pub note_count: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub first_author: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub first_note_body_snippet: Option<String>,
    pub first_note_at_iso: String,
    pub last_note_at_iso: String,
    pub resolvable: bool,
    pub resolved: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub position_new_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub position_new_line: Option<i64>,
}

pub struct DiscussionListResult {
    pub discussions: Vec<DiscussionListRow>,
    pub total_count: i64,
}

#[derive(Serialize)]
pub struct DiscussionListResultJson {
    pub discussions: Vec<DiscussionListRowJson>,
    pub total_count: i64,
    pub showing: usize,
}

The From impl truncates first_note_body to ~120 chars for the snippet.

3c. SQL Query

File: src/cli/commands/list.rs

pub fn query_discussions(
    conn: &Connection,
    filters: &DiscussionListFilters,
    config: &Config,
) -> Result<DiscussionListResult> {

Core query:

SELECT
    d.id,
    d.gitlab_discussion_id,
    d.noteable_type,
    COALESCE(i.iid, m.iid) AS parent_iid,
    COALESCE(i.title, m.title) AS parent_title,
    p.path_with_namespace AS project_path,
    d.individual_note,
    (SELECT COUNT(*) FROM notes n2 WHERE n2.discussion_id = d.id AND n2.is_system = 0) AS note_count,
    (SELECT n3.author_username FROM notes n3 WHERE n3.discussion_id = d.id ORDER BY n3.position LIMIT 1) AS first_author,
    (SELECT n4.body FROM notes n4 WHERE n4.discussion_id = d.id AND n4.is_system = 0 ORDER BY n4.position LIMIT 1) AS first_note_body,
    d.first_note_at,
    d.last_note_at,
    d.resolvable,
    d.resolved,
    (SELECT n5.position_new_path FROM notes n5 WHERE n5.discussion_id = d.id AND n5.position_new_path IS NOT NULL LIMIT 1) AS position_new_path,
    (SELECT n5.position_new_line FROM notes n5 WHERE n5.discussion_id = d.id AND n5.position_new_line IS NOT NULL LIMIT 1) AS position_new_line
FROM discussions d
JOIN projects p ON d.project_id = p.id
LEFT JOIN issues i ON d.issue_id = i.id
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
{where_sql}
ORDER BY {sort_column} {order}
LIMIT ?

Performance note: The correlated subqueries for note_count, first_author, etc. are fine because discussions are always filtered to a specific issue/MR (50-200 rows). For the unscoped case (all discussions in a project), the LIMIT clause keeps it bounded.

3d. Filters struct

pub struct DiscussionListFilters {
    pub limit: usize,
    pub project: Option<String>,
    pub for_issue_iid: Option<i64>,
    pub for_mr_iid: Option<i64>,
    pub resolution: Option<String>,
    pub since: Option<String>,
    pub path: Option<String>,
    pub noteable_type: Option<String>,
    pub sort: String,
    pub order: String,
}

Where-clause construction follows the exact pattern from query_notes():

  • for_issue_iid → subquery to resolve issue ID from IID + project
  • for_mr_iid → subquery to resolve MR ID from IID + project
  • resolutiond.resolvable = 1 AND d.resolved = 0/1
  • sinced.first_note_at >= ? (using parse_since())
  • pathEXISTS (SELECT 1 FROM notes n WHERE n.discussion_id = d.id AND n.position_new_path LIKE ?)
  • noteable_typed.noteable_type = ?

3e. Handler wiring

File: src/main.rs

Add match arm:

Some(Commands::Discussions(args)) => handle_discussions(cli.config.as_deref(), args, robot_mode),

Handler function:

fn handle_discussions(
    config_override: Option<&str>,
    args: DiscussionsArgs,
    robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let start = std::time::Instant::now();
    let config = Config::load(config_override)?;
    let db_path = get_db_path(config.storage.db_path.as_deref());
    let conn = create_connection(&db_path)?;

    let order = if args.asc { "asc" } else { "desc" };
    let filters = DiscussionListFilters {
        limit: args.limit,
        project: args.project,
        for_issue_iid: args.for_issue,
        for_mr_iid: args.for_mr,
        resolution: args.resolution,
        since: args.since,
        path: args.path,
        noteable_type: args.noteable_type,
        sort: args.sort,
        order: order.to_string(),
    };

    let result = query_discussions(&conn, &filters, &config)?;

    let format = if robot_mode && args.format == "table" {
        "json"
    } else {
        &args.format
    };

    match format {
        "json" => print_list_discussions_json(
            &result,
            start.elapsed().as_millis() as u64,
            args.fields.as_deref(),
        ),
        "jsonl" => print_list_discussions_jsonl(&result),
        _ => print_list_discussions(&result),
    }

    Ok(())
}

3f. Print functions

File: src/cli/commands/list.rs

Follow same pattern as print_list_notes_json:

pub fn print_list_discussions_json(
    result: &DiscussionListResult,
    elapsed_ms: u64,
    fields: Option<&[String]>,
) {
    let json_result = DiscussionListResultJson::from(result);
    let meta = RobotMeta { elapsed_ms };
    let output = serde_json::json!({
        "ok": true,
        "data": json_result,
        "meta": meta,
    });
    let mut output = output;
    if let Some(f) = fields {
        let expanded = expand_fields_preset(f, "discussions");
        filter_fields(&mut output, "discussions", &expanded);
    }
    match serde_json::to_string(&output) {
        Ok(json) => println!("{json}"),
        Err(e) => eprintln!("Error serializing to JSON: {e}"),
    }
}

Table view: compact format showing discussion_id (first 8 chars), first author, note count, resolved status, path, snippet.

3g. Fields preset

File: src/cli/robot.rs

"discussions" => [
    "gitlab_discussion_id", "parent_iid", "note_count",
    "resolvable", "resolved", "first_author"
]
    .iter()
    .map(|s| (*s).to_string())
    .collect(),

Tests

Test 1: Basic query returns discussions with gitlab_discussion_id

#[test]
fn query_discussions_basic() {
    let conn = create_test_db();
    insert_project(&conn, 1);
    insert_mr(&conn, 1, 1, 99, "Test MR");
    insert_discussion(&conn, 1, "hexhex123", 1, None, Some(1), "MergeRequest");
    insert_note_in_discussion(&conn, 1, 500, 1, 1, "alice", "first comment");
    insert_note_in_discussion(&conn, 2, 501, 1, 1, "bob", "reply");

    let filters = DiscussionListFilters::default_for_mr(99);
    let result = query_discussions(&conn, &filters, &Config::default()).unwrap();

    assert_eq!(result.discussions.len(), 1);
    assert_eq!(result.discussions[0].gitlab_discussion_id, "hexhex123");
    assert_eq!(result.discussions[0].note_count, 2);
    assert_eq!(result.discussions[0].first_author.as_deref(), Some("alice"));
}

Test 2: Resolution filter

#[test]
fn query_discussions_resolution_filter() {
    let conn = create_test_db();
    // Insert 2 discussions: one resolved, one unresolved
    // ...
    let filters = DiscussionListFilters {
        resolution: Some("unresolved".to_string()),
        ..DiscussionListFilters::default_for_mr(99)
    };
    let result = query_discussions(&conn, &filters, &Config::default()).unwrap();
    assert_eq!(result.total_count, 1);
    assert!(!result.discussions[0].resolved);
}

Test 3: Path filter

#[test]
fn query_discussions_path_filter() {
    // Insert discussions: one with diff notes on src/auth.rs, one general
    // Filter by path "src/auth.rs"
    // Assert only the diff note discussion is returned
}

Test 4: JSON serialization round-trip

#[test]
fn discussion_list_json_serialization() {
    let row = DiscussionListRow {
        id: 1,
        gitlab_discussion_id: "abc123".to_string(),
        noteable_type: "MergeRequest".to_string(),
        parent_iid: Some(99),
        parent_title: Some("Fix auth".to_string()),
        project_path: "group/repo".to_string(),
        individual_note: false,
        note_count: 3,
        first_author: Some("alice".to_string()),
        first_note_body: Some("This is a very long comment that should be truncated...".to_string()),
        first_note_at: 1_700_000_000_000,
        last_note_at: 1_700_001_000_000,
        resolvable: true,
        resolved: false,
        position_new_path: Some("src/auth.rs".to_string()),
        position_new_line: Some(42),
    };

    let json_row = DiscussionListRowJson::from(&row);
    let value = serde_json::to_value(&json_row).unwrap();
    assert_eq!(value["gitlab_discussion_id"], "abc123");
    assert_eq!(value["note_count"], 3);
    assert!(value["first_note_body_snippet"].as_str().unwrap().len() <= 120);
}

Test 5: Fields filtering works for discussions

#[test]
fn discussions_fields_minimal_preset() {
    let expanded = expand_fields_preset(&["minimal".to_string()], "discussions");
    assert!(expanded.contains(&"gitlab_discussion_id".to_string()));
    assert!(expanded.contains(&"parent_iid".to_string()));
}

4. Fix Robot-Docs Response Schemas

Why

The notes command robot-docs says:

"data": {"notes": "[NoteListRowJson]", "total_count": "int", "showing": "int"}

An agent sees [NoteListRowJson] — a Rust type name — and has no idea what fields are available. Compare with the issues command which inline-lists every field. This forces agents into trial-and-error field discovery.

Changes Required

File: src/main.rs, robot-docs JSON block

4a. Notes response_schema

Replace:

"data": {"notes": "[NoteListRowJson]", "total_count": "int", "showing": "int"}

With:

"data": {
    "notes": "[{id:int, gitlab_id:int, author_username:string, body:string?, note_type:string?, is_system:bool, created_at_iso:string, updated_at_iso:string, position_new_path:string?, position_new_line:int?, position_old_path:string?, position_old_line:int?, resolvable:bool, resolved:bool, resolved_by:string?, noteable_type:string?, parent_iid:int?, parent_title:string?, project_path:string, gitlab_discussion_id:string}]",
    "total_count": "int",
    "showing": "int"
}

4b. Add discussions response_schema

"discussions": {
    "description": "List discussions with thread-level metadata",
    "flags": [
        "--limit/-n <N>",
        "--for-issue <iid>",
        "--for-mr <iid>",
        "-p/--project <path>",
        "--resolution <unresolved|resolved>",
        "--since <period>",
        "--path <filepath>",
        "--noteable-type <Issue|MergeRequest>",
        "--sort <first_note|last_note>",
        "--asc",
        "--fields <list|minimal>",
        "--format <table|json|jsonl>"
    ],
    "robot_flags": ["--format json", "--fields minimal"],
    "example": "lore --robot discussions --for-mr 99 --resolution unresolved",
    "response_schema": {
        "ok": "bool",
        "data": {
            "discussions": "[{gitlab_discussion_id:string, noteable_type:string, parent_iid:int?, parent_title:string?, project_path:string, individual_note:bool, note_count:int, first_author:string?, first_note_body_snippet:string?, first_note_at_iso:string, last_note_at_iso:string, resolvable:bool, resolved:bool, position_new_path:string?, position_new_line:int?}]",
            "total_count": "int",
            "showing": "int"
        },
        "meta": {"elapsed_ms": "int"}
    }
}

4c. Add to glab_equivalents

{
    "glab": "glab api /projects/:id/merge_requests/:iid/discussions",
    "lore": "lore -J discussions --for-mr <iid>",
    "note": "Includes note counts, first author, resolution status, file positions"
}

4d. Update show response_schema

Update the issues and mrs show schemas to reflect that discussions now include gitlab_discussion_id.

4e. Add to lore_exclusive list

"discussions: Thread-level discussion listing with gitlab_discussion_id for API integration"

Tests

No code tests needed for robot-docs (it's static JSON). Verified by running lore robot-docs and inspecting output.


Delivery Order

  1. Change 1 (notes output) — standalone, no dependencies. Can be released immediately.
  2. Change 2 (show output) — standalone, no dependencies. Can be released alongside 1.
  3. Change 4 (robot-docs) — depends on 1 and 2 being done so schemas are accurate.
  4. Change 3 (discussions command) — largest change, depends on 1 for design consistency.

Changes 1 and 2 can be done in parallel. Change 3 is independent but should come after 1+2 are reviewed to avoid rework if the field naming or serialization approach changes.


Validation Criteria

After all changes:

  1. An agent can run lore -J notes --for-mr 3929 --contains "really do prefer" and get gitlab_discussion_id in the response
  2. An agent can run lore -J discussions --for-mr 3929 --resolution unresolved to see all open threads with their IDs
  3. An agent can run lore -J mrs 3929 and see gitlab_discussion_id on each discussion group
  4. lore robot-docs lists actual field names for all commands
  5. All existing tests still pass
  6. No clippy warnings (pedantic + nursery)