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

@@ -112,6 +112,20 @@ impl GitLabClient {
self.request("/api/v4/version").await
}
pub async fn get_issue_by_iid(&self, gitlab_project_id: i64, iid: i64) -> Result<GitLabIssue> {
let path = format!("/api/v4/projects/{gitlab_project_id}/issues/{iid}");
self.request(&path).await
}
pub async fn get_mr_by_iid(
&self,
gitlab_project_id: i64,
iid: i64,
) -> Result<GitLabMergeRequest> {
let path = format!("/api/v4/projects/{gitlab_project_id}/merge_requests/{iid}");
self.request(&path).await
}
const MAX_RETRIES: u32 = 3;
async fn request<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
@@ -848,4 +862,143 @@ mod tests {
let result = parse_link_header_next(&headers);
assert!(result.is_none());
}
// ─────────────────────────────────────────────────────────────────
// get_issue_by_iid / get_mr_by_iid
// ─────────────────────────────────────────────────────────────────
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn mock_issue_json(iid: i64) -> serde_json::Value {
serde_json::json!({
"id": 1000 + iid,
"iid": iid,
"project_id": 42,
"title": format!("Issue #{iid}"),
"description": null,
"state": "opened",
"created_at": "2024-01-15T10:00:00.000Z",
"updated_at": "2024-01-16T12:00:00.000Z",
"closed_at": null,
"author": { "id": 1, "username": "alice", "name": "Alice", "avatar_url": null },
"assignees": [],
"labels": ["bug"],
"milestone": null,
"due_date": null,
"web_url": format!("https://gitlab.example.com/g/p/-/issues/{iid}")
})
}
fn mock_mr_json(iid: i64) -> serde_json::Value {
serde_json::json!({
"id": 2000 + iid,
"iid": iid,
"project_id": 42,
"title": format!("MR !{iid}"),
"description": null,
"state": "opened",
"draft": false,
"work_in_progress": false,
"source_branch": "feat",
"target_branch": "main",
"sha": "abc123",
"references": { "short": format!("!{iid}"), "full": format!("g/p!{iid}") },
"detailed_merge_status": "mergeable",
"created_at": "2024-02-01T08:00:00.000Z",
"updated_at": "2024-02-02T09:00:00.000Z",
"merged_at": null,
"closed_at": null,
"author": { "id": 2, "username": "bob", "name": "Bob", "avatar_url": null },
"merge_user": null,
"merged_by": null,
"labels": [],
"assignees": [],
"reviewers": [],
"web_url": format!("https://gitlab.example.com/g/p/-/merge_requests/{iid}"),
"merge_commit_sha": null,
"squash_commit_sha": null
})
}
fn test_client(base_url: &str) -> GitLabClient {
GitLabClient::new(base_url, "test-token", Some(1000.0))
}
#[tokio::test]
async fn get_issue_by_iid_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v4/projects/42/issues/7"))
.and(header("PRIVATE-TOKEN", "test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_issue_json(7)))
.mount(&server)
.await;
let client = test_client(&server.uri());
let issue = client.get_issue_by_iid(42, 7).await.unwrap();
assert_eq!(issue.iid, 7);
assert_eq!(issue.title, "Issue #7");
assert_eq!(issue.state, "opened");
}
#[tokio::test]
async fn get_issue_by_iid_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v4/projects/42/issues/999"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = test_client(&server.uri());
let err = client.get_issue_by_iid(42, 999).await.unwrap_err();
assert!(
matches!(err, LoreError::GitLabNotFound { .. }),
"Expected GitLabNotFound, got: {err:?}"
);
}
#[tokio::test]
async fn get_mr_by_iid_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v4/projects/42/merge_requests/99"))
.and(header("PRIVATE-TOKEN", "test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_mr_json(99)))
.mount(&server)
.await;
let client = test_client(&server.uri());
let mr = client.get_mr_by_iid(42, 99).await.unwrap();
assert_eq!(mr.iid, 99);
assert_eq!(mr.title, "MR !99");
assert_eq!(mr.source_branch, "feat");
assert_eq!(mr.target_branch, "main");
}
#[tokio::test]
async fn get_mr_by_iid_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v4/projects/42/merge_requests/999"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = test_client(&server.uri());
let err = client.get_mr_by_iid(42, 999).await.unwrap_err();
assert!(
matches!(err, LoreError::GitLabNotFound { .. }),
"Expected GitLabNotFound, got: {err:?}"
);
}
}