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:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user