diff --git a/.claude/plan.md b/.claude/plan.md new file mode 100644 index 0000000..4f1b228 --- /dev/null +++ b/.claude/plan.md @@ -0,0 +1,99 @@ +# Plan: Add Colors to Sync Command Output + +## Current State + +The sync output has three layers, each needing color treatment: + +### Layer 1: Stage Lines (during sync) +``` + ✓ Issues 10 issues from 2 projects 4.2s + ✓ Status 3 statuses updated · 5 seen 4.2s + vs/typescript-code 2 issues · 1 statuses updated + ✓ MRs 5 merge requests from 2 projects 12.3s + vs/python-code 3 MRs · 10 discussions + ✓ Docs 1,200 documents generated 8.1s + ✓ Embed 3,400 chunks embedded 45.2s +``` + +**What's uncolored:** icons, labels, numbers, elapsed times, sub-row project paths, failure counts in parentheses. + +### Layer 2: Summary (after sync) +``` + Synced 10 issues and 5 MRs in 42.3s + 120 discussions · 45 events · 12 diffs · 3 statuses updated + 1,200 docs regenerated · 3,400 embedded +``` + +**What's already colored:** headline ("Synced" = green bold, "Sync completed with issues" = warning bold), issue/MR counts (bold), error line (red). Detail lines are all dim. + +### Layer 3: Timing breakdown (`-t` flag) +``` +── Timing ────────────────────── + issues .............. 4.2s + merge_requests ...... 12.3s +``` + +**What's already colored:** dots (dim), time (bold), errors (red), rate limits (warning). + +--- + +## Color Plan + +Using only existing `Theme` methods — no new colors needed. + +### Stage Lines (`format_stage_line` + callers in sync.rs) + +| Element | Current | Proposed | Theme method | +|---------|---------|----------|-------------| +| Icon (✓/⚠) | plain | green for success, yellow for warning | `Theme::success()` / `Theme::warning()` | +| Label ("Issues", "MRs", etc.) | plain | bold | `Theme::bold()` | +| Numbers in summary text | plain | bold | `Theme::bold()` (just the count) | +| Elapsed time | plain | muted gray | `Theme::timing()` | +| Failure text in parens | plain | warning/error color | `Theme::warning()` | + +### Sub-rows (project breakdown lines) + +| Element | Current | Proposed | +|---------|---------|----------| +| Project path | dim | `Theme::muted()` (slightly brighter than dim) | +| Counts (numbers only) | dim | `Theme::dim()` but numbers in normal weight | +| Error/failure counts | dim | `Theme::warning()` | +| Middle dots | dim | keep dim (they're separators, should recede) | + +### Summary (`print_sync`) + +| Element | Current | Proposed | +|---------|---------|----------| +| Issue/MR counts in headline | bold only | `Theme::info()` + bold (cyan numbers pop) | +| Time in headline | plain | `Theme::timing()` | +| Detail line numbers | all dim | numbers in `Theme::info()`, rest stays dim | +| Doc line numbers | all dim | numbers in `Theme::info()`, rest stays dim | +| "Already up to date" time | plain | `Theme::timing()` | + +--- + +## Files to Change + +1. **`src/cli/progress.rs`** — `format_stage_line()`: apply color to icon, bold to label, `Theme::timing()` to elapsed +2. **`src/cli/commands/sync.rs`** — + - Pass colored icons to `format_stage_line` / `emit_stage_line` / `emit_stage_block` + - Color failure text in `append_failures()` + - Color numbers and time in `print_sync()` + - Color error/failure counts in sub-row functions (`issue_sub_rows`, `mr_sub_rows`, `status_sub_rows`) + +## Approach + +- `format_stage_line` already receives the icon string — color it before passing +- Add a `color_icon` helper that applies success/warning color to the icon glyph +- Bold the label in `format_stage_line` +- Apply `Theme::timing()` to elapsed in `format_stage_line` +- In `append_failures`, wrap failure text in `Theme::warning()` +- In `print_sync`, wrap count numbers with `Theme::info().bold()` +- In sub-row functions, apply `Theme::warning()` to error/failure parts only (keep rest dim) + +## Non-goals + +- No changes to robot mode (JSON output) +- No changes to dry-run output (already reasonably colored) +- No new Theme colors — use existing palette +- No changes to timing breakdown (already colored) diff --git a/docs/plan-expose-discussion-ids.md b/docs/plan-expose-discussion-ids.md new file mode 100644 index 0000000..b59439f --- /dev/null +++ b/docs/plan-expose-discussion-ids.md @@ -0,0 +1,1104 @@ +# 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) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index db4f342..1c6babe 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -750,6 +750,7 @@ pub struct GenerateDocsArgs { #[command(after_help = "\x1b[1mExamples:\x1b[0m lore sync # Full pipeline: ingest + docs + embed lore sync --no-embed # Skip embedding step + lore sync --no-status # Skip work-item status enrichment lore sync --full --force # Full re-sync, override stale lock lore sync --dry-run # Preview what would change")] pub struct SyncArgs { @@ -783,6 +784,10 @@ pub struct SyncArgs { #[arg(long = "no-file-changes")] pub no_file_changes: bool, + /// Skip work-item status enrichment via GraphQL (overrides config) + #[arg(long = "no-status")] + pub no_status: bool, + /// Preview what would be synced without making changes #[arg(long, overrides_with = "no_dry_run")] pub dry_run: bool, diff --git a/src/main.rs b/src/main.rs index fbf0096..31845f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2046,6 +2046,9 @@ async fn handle_sync_cmd( if args.no_file_changes { config.sync.fetch_mr_file_changes = false; } + if args.no_status { + config.sync.fetch_work_item_status = false; + } let options = SyncOptions { full: args.full && !args.no_full, force: args.force && !args.no_force, @@ -2337,7 +2340,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box generate-docs -> embed", - "flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--no-file-changes", "--dry-run", "--no-dry-run"], + "flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--no-file-changes", "--no-status", "--dry-run", "--no-dry-run"], "example": "lore --robot sync", "response_schema": { "ok": "bool", @@ -2478,7 +2481,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box", "-p/--project", "--since ", "--depth ", "--no-mentions", "-n/--limit", "--fields ", "--max-seeds", "--max-entities", "--max-evidence"], "query_syntax": { - "search": "Any text -> hybrid search seeding (FTS + vector)", + "search": "Any text -> hybrid search seeding (FTS5 + vector)", "entity_direct": "issue:N, i:N, mr:N, m:N -> direct entity seeding (no search, no Ollama)" }, "example": "lore --robot timeline issue:42", @@ -2642,11 +2645,14 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box Result<(), Box issues, 'time' -> timeline, 'sea' -> search"