# 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`): ```sql 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`): ```rust pub struct NoteListRow { pub id: i64, pub gitlab_id: i64, pub author_username: String, pub body: Option, pub note_type: Option, pub is_system: bool, pub created_at: i64, pub updated_at: i64, pub position_new_path: Option, pub position_new_line: Option, pub position_old_path: Option, pub position_old_line: Option, pub resolvable: bool, pub resolved: bool, pub resolved_by: Option, pub noteable_type: Option, pub parent_iid: Option, pub parent_title: Option, pub project_path: String, } ``` **JSON struct** (`src/cli/commands/list.rs:1063-1094`): ```rust 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. ```sql -- 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 ```rust pub struct NoteListRow { // ... existing fields ... pub project_path: String, pub gitlab_discussion_id: String, // ADD } ``` And in the `query_map` closure (line ~1407): ```rust 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 ```rust pub struct NoteListRowJson { // ... existing fields ... pub project_path: String, pub gitlab_discussion_id: String, // ADD } ``` And in the `From<&NoteListRow>` impl (line ~1096): ```rust 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 ```rust "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 ```rust #[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 ```rust #[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 ```rust #[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`): ```rust pub struct DiscussionDetail { pub notes: Vec, pub individual_note: bool, } ``` **MR discussions** (`src/cli/commands/show.rs:37-40`): ```rust pub struct MrDiscussionDetail { pub notes: Vec, pub individual_note: bool, } ``` **JSON equivalents** (`show.rs:1001-1003` and `show.rs:1100-1103`): ```rust pub struct DiscussionDetailJson { pub notes: Vec, pub individual_note: bool, } pub struct MrDiscussionDetailJson { pub notes: Vec, pub individual_note: bool, } ``` **Queries** (`show.rs:325-328` and `show.rs:537-540`): ```sql 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` ```rust pub struct DiscussionDetail { pub gitlab_discussion_id: String, // ADD pub notes: Vec, pub individual_note: bool, } pub struct MrDiscussionDetail { pub gitlab_discussion_id: String, // ADD pub notes: Vec, pub individual_note: bool, } ``` #### 2b. Add field to JSON structs ```rust pub struct DiscussionDetailJson { pub gitlab_discussion_id: String, // ADD pub notes: Vec, pub individual_note: bool, } pub struct MrDiscussionDetailJson { pub gitlab_discussion_id: String, // ADD pub notes: Vec, pub individual_note: bool, } ``` #### 2c. Update queries to SELECT gitlab_discussion_id **Issue discussions** (`show.rs:325`): ```sql SELECT id, gitlab_discussion_id, individual_note FROM discussions WHERE issue_id = ? ORDER BY first_note_at ``` **MR discussions** (`show.rs:537`): ```sql 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`): ```rust 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::, _>>()?; ``` And where discussions are constructed (`show.rs:361`): ```rust 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 ```rust 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 ```rust #[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 ```rust #[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 ```json { "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`): ```rust /// List discussions #[command(visible_alias = "discussion")] Discussions(DiscussionsArgs), ``` Args struct: ```rust #[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>, /// 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, /// Filter to discussions on a specific MR IID #[arg(long, conflicts_with = "for_issue", help_heading = "Filters")] pub for_mr: Option, /// Filter by project path #[arg(short = 'p', long, help_heading = "Filters")] pub project: Option, /// Filter by resolution status (unresolved, resolved) #[arg(long, value_parser = ["unresolved", "resolved"], help_heading = "Filters")] pub resolution: Option, /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) #[arg(long, help_heading = "Filters")] pub since: Option, /// Filter by file path (exact match or prefix with trailing /) #[arg(long, help_heading = "Filters")] pub path: Option, /// Filter by noteable type (Issue, MergeRequest) #[arg(long, value_parser = ["Issue", "MergeRequest"], help_heading = "Filters")] pub noteable_type: Option, /// 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` ```rust #[derive(Debug)] pub struct DiscussionListRow { pub id: i64, pub gitlab_discussion_id: String, pub noteable_type: String, pub parent_iid: Option, pub parent_title: Option, pub project_path: String, pub individual_note: bool, pub note_count: i64, pub first_author: Option, pub first_note_body: Option, pub first_note_at: i64, pub last_note_at: i64, pub resolvable: bool, pub resolved: bool, pub position_new_path: Option, pub position_new_line: Option, } #[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, #[serde(skip_serializing_if = "Option::is_none")] pub parent_title: Option, pub project_path: String, pub individual_note: bool, pub note_count: i64, #[serde(skip_serializing_if = "Option::is_none")] pub first_author: Option, #[serde(skip_serializing_if = "Option::is_none")] pub first_note_body_snippet: Option, 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, #[serde(skip_serializing_if = "Option::is_none")] pub position_new_line: Option, } pub struct DiscussionListResult { pub discussions: Vec, pub total_count: i64, } #[derive(Serialize)] pub struct DiscussionListResultJson { pub discussions: Vec, 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` ```rust pub fn query_discussions( conn: &Connection, filters: &DiscussionListFilters, config: &Config, ) -> Result { ``` Core query: ```sql 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 ```rust pub struct DiscussionListFilters { pub limit: usize, pub project: Option, pub for_issue_iid: Option, pub for_mr_iid: Option, pub resolution: Option, pub since: Option, pub path: Option, pub noteable_type: Option, 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 - `resolution` → `d.resolvable = 1 AND d.resolved = 0/1` - `since` → `d.first_note_at >= ?` (using `parse_since()`) - `path` → `EXISTS (SELECT 1 FROM notes n WHERE n.discussion_id = d.id AND n.position_new_path LIKE ?)` - `noteable_type` → `d.noteable_type = ?` #### 3e. Handler wiring **File**: `src/main.rs` Add match arm: ```rust Some(Commands::Discussions(args)) => handle_discussions(cli.config.as_deref(), args, robot_mode), ``` Handler function: ```rust fn handle_discussions( config_override: Option<&str>, args: DiscussionsArgs, robot_mode: bool, ) -> Result<(), Box> { 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`: ```rust 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` ```rust "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 ```rust #[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 ```rust #[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 ```rust #[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 ```rust #[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 ```rust #[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: ```json "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: ```json "data": {"notes": "[NoteListRowJson]", "total_count": "int", "showing": "int"} ``` With: ```json "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 ```json "discussions": { "description": "List discussions with thread-level metadata", "flags": [ "--limit/-n ", "--for-issue ", "--for-mr ", "-p/--project ", "--resolution ", "--since ", "--path ", "--noteable-type ", "--sort ", "--asc", "--fields ", "--format " ], "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 ```json { "glab": "glab api /projects/:id/merge_requests/:iid/discussions", "lore": "lore -J discussions --for-mr ", "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 ```json "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)