feat(cli): add 'lore related' semantic similarity command (bd-8con)

Adds 'lore related' / 'lore similar' command for discovering semantically
related issues and MRs using vector embeddings.

Two modes:
- Entity mode: find entities similar to a specific issue/MR
- Query mode: embed free text and find matching entities

Includes distance-to-similarity conversion, label intersection,
human and robot output formatters, and 11 unit tests.
This commit is contained in:
teernisse
2026-02-19 08:01:55 -05:00
parent 3e96f19a11
commit c8dece8c60
27 changed files with 4066 additions and 33 deletions

View File

@@ -377,3 +377,120 @@ fn test_sync_completed_from_bootstrap_resets_navigation_and_state() {
assert_eq!(app.navigation.depth(), 1);
assert!(!app.state.bootstrap.sync_started);
}
#[test]
fn test_sync_completed_flushes_entity_caches() {
use crate::message::EntityKey;
use crate::state::issue_detail::{IssueDetailData, IssueMetadata};
use crate::state::mr_detail::{MrDetailData, MrMetadata};
use crate::state::{CachedIssuePayload, CachedMrPayload};
use crate::view::common::cross_ref::CrossRef;
let mut app = test_app();
// Populate caches with dummy data.
let issue_key = EntityKey::issue(1, 42);
app.state.issue_cache.put(
issue_key,
CachedIssuePayload {
data: IssueDetailData {
metadata: IssueMetadata {
iid: 42,
project_path: "g/p".into(),
title: "Test".into(),
description: String::new(),
state: "opened".into(),
author: "alice".into(),
assignees: vec![],
labels: vec![],
milestone: None,
due_date: None,
created_at: 0,
updated_at: 0,
web_url: String::new(),
discussion_count: 0,
},
cross_refs: Vec::<CrossRef>::new(),
},
discussions: vec![],
},
);
let mr_key = EntityKey::mr(1, 99);
app.state.mr_cache.put(
mr_key,
CachedMrPayload {
data: MrDetailData {
metadata: MrMetadata {
iid: 99,
project_path: "g/p".into(),
title: "MR".into(),
description: String::new(),
state: "opened".into(),
draft: false,
author: "bob".into(),
assignees: vec![],
reviewers: vec![],
labels: vec![],
source_branch: "feat".into(),
target_branch: "main".into(),
merge_status: String::new(),
created_at: 0,
updated_at: 0,
merged_at: None,
web_url: String::new(),
discussion_count: 0,
file_change_count: 0,
},
cross_refs: Vec::<CrossRef>::new(),
file_changes: vec![],
},
discussions: vec![],
},
);
assert_eq!(app.state.issue_cache.len(), 1);
assert_eq!(app.state.mr_cache.len(), 1);
// Sync completes — caches should be flushed.
app.update(Msg::SyncCompleted { elapsed_ms: 500 });
assert!(
app.state.issue_cache.is_empty(),
"issue cache should be flushed after sync"
);
assert!(
app.state.mr_cache.is_empty(),
"MR cache should be flushed after sync"
);
}
#[test]
fn test_sync_completed_refreshes_current_detail_view() {
use crate::message::EntityKey;
use crate::state::LoadState;
let mut app = test_app();
// Navigate to an issue detail screen.
let key = EntityKey::issue(1, 42);
app.update(Msg::NavigateTo(Screen::IssueDetail(key)));
// Simulate load completion so LoadState goes to Idle.
app.state.set_loading(
Screen::IssueDetail(EntityKey::issue(1, 42)),
LoadState::Idle,
);
// Sync completes while viewing the detail.
app.update(Msg::SyncCompleted { elapsed_ms: 300 });
// The detail screen should have been set to Refreshing.
assert_eq!(
*app.state
.load_state
.get(&Screen::IssueDetail(EntityKey::issue(1, 42))),
LoadState::Refreshing,
"detail view should refresh after sync"
);
}

View File

@@ -542,6 +542,11 @@ impl LoreApp {
Msg::SyncCompleted { elapsed_ms } => {
self.state.sync.complete(elapsed_ms);
// Flush entity caches — sync may have updated any entity's
// metadata, discussions, or cross-refs in the DB.
self.state.issue_cache.clear();
self.state.mr_cache.clear();
// If we came from Bootstrap, replace nav history with Dashboard.
if *self.navigation.current() == Screen::Bootstrap {
self.state.bootstrap.sync_started = false;
@@ -557,6 +562,19 @@ impl LoreApp {
self.state.set_loading(dashboard.clone(), load_state);
let _handle = self.supervisor.submit(TaskKey::LoadScreen(dashboard));
}
// If currently on a detail view, refresh it so the user sees
// updated data without navigating away and back.
let current = self.navigation.current().clone();
match &current {
Screen::IssueDetail(_) | Screen::MrDetail(_) => {
self.state
.set_loading(current.clone(), LoadState::Refreshing);
let _handle = self.supervisor.submit(TaskKey::LoadScreen(current));
}
_ => {}
}
Cmd::none()
}
Msg::SyncCancelled => {