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:
- Add
gitlab_discussion_idto notes output - Add
gitlab_discussion_idto show command discussion groups - Add a standalone
discussionslist command - 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(alongsideNotesArgs,IssuesArgs) - Query + print functions:
src/cli/commands/list.rs(alongsidequery_notes,print_list_notes_json) - Handler:
src/main.rs(alongsidehandle_notes) - Tests:
src/cli/commands/list_tests.rs - Robot-docs:
src/main.rsrobot-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 + projectfor_mr_iid→ subquery to resolve MR ID from IID + projectresolution→d.resolvable = 1 AND d.resolved = 0/1since→d.first_note_at >= ?(usingparse_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:
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
- Change 1 (notes output) — standalone, no dependencies. Can be released immediately.
- Change 2 (show output) — standalone, no dependencies. Can be released alongside 1.
- Change 4 (robot-docs) — depends on 1 and 2 being done so schemas are accurate.
- 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:
- An agent can run
lore -J notes --for-mr 3929 --contains "really do prefer"and getgitlab_discussion_idin the response - An agent can run
lore -J discussions --for-mr 3929 --resolution unresolvedto see all open threads with their IDs - An agent can run
lore -J mrs 3929and seegitlab_discussion_idon each discussion group lore robot-docslists actual field names for all commands- All existing tests still pass
- No clippy warnings (pedantic + nursery)