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

1105 lines
32 KiB
Markdown

# 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<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`):
```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<NoteDetail>,
pub individual_note: bool,
}
```
**MR discussions** (`src/cli/commands/show.rs:37-40`):
```rust
pub struct MrDiscussionDetail {
pub notes: Vec<MrNoteDetail>,
pub individual_note: bool,
}
```
**JSON equivalents** (`show.rs:1001-1003` and `show.rs:1100-1103`):
```rust
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`):
```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<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
```rust
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`):
```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::<std::result::Result<Vec<_>, _>>()?;
```
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<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`
```rust
#[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`
```rust
pub fn query_discussions(
conn: &Connection,
filters: &DiscussionListFilters,
config: &Config,
) -> Result<DiscussionListResult> {
```
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<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
- `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<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`:
```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 <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
```json
{
"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
```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)