/
codenik
/
codenik-tools
Обзор
Документация
Войти
/
codenik
/
codenik-tools
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
main
crates/plugins/api/gitverse/src/client.rs
695 строк
26 KB
Codenik Wizard
feat(gitverse): impl IssueProvider and MergeRequestProvider
09 май 2026, 00:24
09 май 2026, 00:24
a84956b
Код
Авторство
О чём код?
use async_trait::async_trait; use reqwest::Client; use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT}; use secrecy::{ExposeSecret, SecretString}; use serde::de::DeserializeOwned; use codenik_core::Commit; use codenik_core::{ Branch, Comment, CommitsOpts, CreateIssueInput, Error, IdentityProvider, Issue, IssueFilter, IssueKey, IssueProvider, MergeRequest, MergeRequestProvider, MrFilter, Page, Provider, Repo, RepoFilter, RepoProvider, Result, User, }; use crate::types::{ ApiBranch, ApiCommentRecord, ApiCommit, ApiIssue, ApiPullRequest, ApiRepo, ApiUser, issue_from_api, merge_request_from_api, }; const DEFAULT_BASE_URL: &str = "https://api.gitverse.ru"; const ACCEPT_VERSION: &str = "application/vnd.gitverse.object+json;version=1"; const PROVIDER_NAME: &str = "gitverse"; pub struct GitVerseClient { base_url: String, http: Client, } impl GitVerseClient { pub fn new(token: SecretString) -> Result<Self> { Self::with_base_url(token, DEFAULT_BASE_URL) } pub fn with_base_url(token: SecretString, base_url: impl Into<String>) -> Result<Self> { let mut headers = HeaderMap::new(); let auth = HeaderValue::from_str(&format!("Bearer {}", token.expose_secret())) .map_err(|e| Error::Config(format!("invalid token header: {e}")))?; headers.insert(AUTHORIZATION, auth); headers.insert(ACCEPT, HeaderValue::from_static(ACCEPT_VERSION)); headers.insert(USER_AGENT, HeaderValue::from_static("codenik-tools")); let http = Client::builder() .default_headers(headers) .build() .map_err(|e| Error::Transport(e.to_string()))?; Ok(Self { base_url: base_url.into().trim_end_matches('/').to_string(), http, }) } pub fn base_url(&self) -> &str { &self.base_url } pub(crate) fn url(&self, path: &str) -> String { if path.starts_with('/') { format!("{}{}", self.base_url, path) } else { format!("{}/{}", self.base_url, path) } } pub(crate) async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> { self.get_with_query::<T>(path, &[]).await } pub(crate) async fn get_with_query<T: DeserializeOwned>( &self, path: &str, query: &[(String, String)], ) -> Result<T> { let url = self.url(path); let mut req = self.http.get(&url); if !query.is_empty() { req = req.query(query); } let resp = req .send() .await .map_err(|e| Error::Transport(e.to_string()))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(map_status(PROVIDER_NAME, status.as_u16(), &body)); } resp.json::<T>() .await .map_err(|e| Error::Transport(e.to_string())) } pub(crate) async fn post_json<B: serde::Serialize, T: DeserializeOwned>( &self, path: &str, body: &B, ) -> Result<T> { let url = self.url(path); let resp = self .http .post(&url) .json(body) .send() .await .map_err(|e| Error::Transport(e.to_string()))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(map_status(PROVIDER_NAME, status.as_u16(), &body)); } resp.json::<T>() .await .map_err(|e| Error::Transport(e.to_string())) } } pub(crate) fn parse_scope(s: &str) -> Result<(&str, &str)> { let mut parts = s.splitn(2, '/'); let owner = parts .next() .ok_or_else(|| Error::InvalidInput(format!("scope `{s}` is not owner/repo")))?; let repo = parts .next() .ok_or_else(|| Error::InvalidInput(format!("scope `{s}` is not owner/repo")))?; if owner.is_empty() || repo.is_empty() { return Err(Error::InvalidInput(format!( "scope `{s}` is not owner/repo" ))); } Ok((owner, repo)) } pub(crate) fn map_status(provider: &str, status: u16, body: &str) -> Error { match status { 401 => Error::Unauthorized(body.to_string()), 403 => Error::Forbidden(body.to_string()), 404 => Error::NotFound(body.to_string()), 429 => Error::RateLimited { retry_after_secs: 60, }, _ => Error::Provider { provider: provider.to_string(), status, message: body.to_string(), }, } } impl Provider for GitVerseClient { fn provider_name(&self) -> &str { PROVIDER_NAME } } #[async_trait] impl IdentityProvider for GitVerseClient { async fn get_authenticated_user(&self) -> Result<User> { let api_user: ApiUser = self.get_json("/user").await?; Ok(api_user.into()) } } #[async_trait] impl RepoProvider for GitVerseClient { async fn list_repos(&self, filter: RepoFilter) -> Result<Page<Repo>> { let mut query: Vec<(String, String)> = Vec::new(); if let Some(limit) = filter.pagination.limit { query.push(("per_page".to_string(), limit.to_string())); } if let Some(cursor) = filter.pagination.cursor.as_deref() { query.push(("page".to_string(), cursor.to_string())); } let api: Vec<ApiRepo> = self.get_with_query("/user/repos", &query).await?; Ok(Page::new(api.into_iter().map(Into::into).collect())) } async fn get_repo(&self, owner: &str, repo: &str) -> Result<Repo> { let path = format!("/repos/{owner}/{repo}"); let api: ApiRepo = self.get_json(&path).await?; Ok(api.into()) } async fn list_branches(&self, owner: &str, repo: &str) -> Result<Page<Branch>> { let path = format!("/repos/{owner}/{repo}/branches"); let api: Vec<ApiBranch> = self.get_json(&path).await?; Ok(Page::new(api.into_iter().map(Into::into).collect())) } async fn list_commits( &self, owner: &str, repo: &str, opts: CommitsOpts, ) -> Result<Page<Commit>> { let path = format!("/repos/{owner}/{repo}/commits"); let mut query: Vec<(String, String)> = Vec::new(); if let Some(limit) = opts.pagination.limit { query.push(("per_page".to_string(), limit.to_string())); } if let Some(cursor) = opts.pagination.cursor.as_deref() { query.push(("page".to_string(), cursor.to_string())); } if let Some(branch) = opts.branch.as_deref() { query.push(("sha".to_string(), branch.to_string())); } let api: Vec<ApiCommit> = self.get_with_query(&path, &query).await?; Ok(Page::new(api.into_iter().map(Into::into).collect())) } } #[async_trait] impl IssueProvider for GitVerseClient { async fn list_issues(&self, filter: IssueFilter) -> Result<Page<Issue>> { let scope = filter.scope.as_deref().ok_or_else(|| { Error::InvalidInput("scope (owner/repo) is required for gitverse list_issues".into()) })?; let (owner, repo) = parse_scope(scope)?; let path = format!("/repos/{owner}/{repo}/issues"); let mut query: Vec<(String, String)> = Vec::new(); if let Some(state) = filter.state { let s = match state { codenik_core::IssueState::Open => "open", codenik_core::IssueState::Closed => "closed", codenik_core::IssueState::Other => "all", }; query.push(("state".to_string(), s.to_string())); } if let Some(limit) = filter.pagination.limit { query.push(("per_page".to_string(), limit.to_string())); } if let Some(cursor) = filter.pagination.cursor.as_deref() { query.push(("page".to_string(), cursor.to_string())); } if let Some(assignee) = filter.assignee.as_deref() { query.push(("assignee".to_string(), assignee.to_string())); } if !filter.labels.is_empty() { query.push(("labels".to_string(), filter.labels.join(","))); } let scope_owned = scope.to_string(); let api: Vec<ApiIssue> = self.get_with_query(&path, &query).await?; Ok(Page::new( api.into_iter() .map(|i| issue_from_api(i, &scope_owned)) .collect(), )) } async fn get_issue(&self, key: &IssueKey) -> Result<Issue> { let (owner, repo) = parse_scope(&key.scope)?; let path = format!("/repos/{owner}/{repo}/issues/{}", key.id); let api: ApiIssue = self.get_json(&path).await?; Ok(issue_from_api(api, &key.scope)) } async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> { let (owner, repo) = parse_scope(&input.scope)?; let path = format!("/repos/{owner}/{repo}/issues"); let body = serde_json::json!({ "title": input.title, "body": input.body.unwrap_or_default(), "labels": input.labels, "assignees": input.assignees, }); let scope_owned = input.scope.clone(); let api: ApiIssue = self.post_json(&path, &body).await?; Ok(issue_from_api(api, &scope_owned)) } async fn list_comments(&self, key: &IssueKey) -> Result<Page<Comment>> { let (owner, repo) = parse_scope(&key.scope)?; let path = format!("/repos/{owner}/{repo}/issues/{}/comments", key.id); let api: Vec<ApiCommentRecord> = self.get_json(&path).await?; Ok(Page::new(api.into_iter().map(Into::into).collect())) } async fn add_comment(&self, key: &IssueKey, body: &str) -> Result<Comment> { let (owner, repo) = parse_scope(&key.scope)?; let path = format!("/repos/{owner}/{repo}/issues/{}/comments", key.id); let payload = serde_json::json!({ "body": body }); let api: ApiCommentRecord = self.post_json(&path, &payload).await?; Ok(api.into()) } } #[async_trait] impl MergeRequestProvider for GitVerseClient { async fn list_merge_requests(&self, filter: MrFilter) -> Result<Page<MergeRequest>> { let scope = filter.scope.as_deref().ok_or_else(|| { Error::InvalidInput("scope (owner/repo) is required for gitverse list_merge_requests".into()) })?; let (owner, repo) = parse_scope(scope)?; let path = format!("/repos/{owner}/{repo}/pulls"); let mut query: Vec<(String, String)> = Vec::new(); if let Some(state) = filter.state { let s = match state { codenik_core::MergeRequestState::Open => "open", codenik_core::MergeRequestState::Closed => "closed", codenik_core::MergeRequestState::Merged => "closed", codenik_core::MergeRequestState::Other => "all", }; query.push(("state".to_string(), s.to_string())); } if let Some(limit) = filter.pagination.limit { query.push(("per_page".to_string(), limit.to_string())); } let scope_owned = scope.to_string(); let api: Vec<ApiPullRequest> = self.get_with_query(&path, &query).await?; Ok(Page::new( api.into_iter() .map(|p| merge_request_from_api(p, &scope_owned)) .collect(), )) } async fn get_merge_request( &self, owner: &str, repo: &str, number: u64, ) -> Result<MergeRequest> { let path = format!("/repos/{owner}/{repo}/pulls/{number}"); let scope = format!("{owner}/{repo}"); let api: ApiPullRequest = self.get_json(&path).await?; Ok(merge_request_from_api(api, &scope)) } } #[cfg(test)] mod tests { use super::*; use httpmock::prelude::*; #[tokio::test] async fn provider_name_is_gitverse() { let client = GitVerseClient::with_base_url(SecretString::from("t"), "http://127.0.0.1:1").unwrap(); assert_eq!(client.provider_name(), "gitverse"); } #[tokio::test] async fn get_authenticated_user_parses_response() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method(GET) .path("/user") .header("authorization", "Bearer test-token"); then.status(200) .header("content-type", "application/json") .json_body(serde_json::json!({ "login": "octocat", "full_name": "Mona Lisa", "email": "octocat@example.org", "avatar_url": "https://example.org/a.png", "html_url": "https://example.org/octocat" })); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("test-token"), server.base_url()) .unwrap(); let user = client.get_authenticated_user().await.unwrap(); mock.assert_async().await; assert_eq!(user.login, "octocat"); assert_eq!(user.display_name.as_deref(), Some("Mona Lisa")); assert_eq!(user.email.as_deref(), Some("octocat@example.org")); } #[tokio::test] async fn unauthorized_response_maps_to_unauthorized_error() { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { when.method(GET).path("/user"); then.status(401).body("invalid token"); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("bad"), server.base_url()).unwrap(); let err = client.get_authenticated_user().await.unwrap_err(); assert!(matches!(err, Error::Unauthorized(_))); } #[tokio::test] async fn list_repos_parses_response() { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { when.method(GET).path("/user/repos"); then.status(200).json_body(serde_json::json!([ { "name": "alpha", "full_name": "octo/alpha", "owner": { "login": "octo" }, "private": false }, { "name": "beta", "full_name": "octo/beta", "owner": { "login": "octo" }, "private": true } ])); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("t"), server.base_url()).unwrap(); let page = client.list_repos(RepoFilter::default()).await.unwrap(); assert_eq!(page.items.len(), 2); assert_eq!(page.items[0].name, "alpha"); assert!(page.items[1].is_private); } #[tokio::test] async fn get_repo_parses_response() { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { when.method(GET).path("/repos/octo/alpha"); then.status(200).json_body(serde_json::json!({ "name": "alpha", "full_name": "octo/alpha", "owner": { "login": "octo" }, "default_branch": "main", "private": false, "html_url": "https://example.org/octo/alpha" })); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("t"), server.base_url()).unwrap(); let repo = client.get_repo("octo", "alpha").await.unwrap(); assert_eq!(repo.full_name, "octo/alpha"); assert_eq!(repo.default_branch.as_deref(), Some("main")); } #[tokio::test] async fn list_branches_parses_response() { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { when.method(GET).path("/repos/octo/alpha/branches"); then.status(200).json_body(serde_json::json!([ { "name": "main", "commit": { "sha": "abc" }, "protected": false }, { "name": "dev", "commit": { "sha": "def" } } ])); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("t"), server.base_url()).unwrap(); let page = client.list_branches("octo", "alpha").await.unwrap(); assert_eq!(page.items.len(), 2); assert_eq!(page.items[0].commit_sha, "abc"); assert!(!page.items[1].protected); } #[tokio::test] async fn list_commits_parses_response_and_passes_query() { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { when.method(GET) .path("/repos/octo/alpha/commits") .query_param("per_page", "1") .query_param("sha", "main"); then.status(200).json_body(serde_json::json!([ { "sha": "abc123", "html_url": "https://example.org/c/abc123", "commit": { "message": "init", "author": { "name": "Codenik Wizard", "email": "wizard@codenik.ru", "date": "2026-05-08T20:00:00Z" } } } ])); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("t"), server.base_url()).unwrap(); let opts = CommitsOpts { branch: Some("main".to_string()), pagination: codenik_core::Pagination { limit: Some(1), cursor: None, }, ..Default::default() }; let page = client.list_commits("octo", "alpha", opts).await.unwrap(); assert_eq!(page.items.len(), 1); assert_eq!(page.items[0].sha, "abc123"); assert_eq!(page.items[0].author.as_ref().unwrap().display_name.as_deref(), Some("Codenik Wizard")); } /// Smoke-тест против настоящего API. Запускается только с `--ignored` /// и при наличии `GITVERSE_TOKEN`. #[tokio::test] #[ignore = "smoke test against real GitVerse API"] async fn smoke_get_authenticated_user_against_real_api() { let Ok(token) = std::env::var("GITVERSE_TOKEN") else { eprintln!("GITVERSE_TOKEN not set — skipping smoke test"); return; }; if token.is_empty() { return; } let client = GitVerseClient::new(SecretString::from(token)).unwrap(); let user = client.get_authenticated_user().await.expect("auth failed"); eprintln!("smoke: authenticated as `{}`", user.login); assert!(!user.login.is_empty()); } /// Smoke list_repos: проверяем, что API доступен и парсится. #[tokio::test] #[ignore = "smoke test against real GitVerse API"] async fn smoke_list_repos_against_real_api() { let Ok(token) = std::env::var("GITVERSE_TOKEN") else { return; }; if token.is_empty() { return; } let client = GitVerseClient::new(SecretString::from(token)).unwrap(); let filter = RepoFilter { pagination: codenik_core::Pagination { limit: Some(5), cursor: None, }, ..Default::default() }; let page = client.list_repos(filter).await.expect("list_repos failed"); eprintln!("smoke: list_repos returned {} repo(s)", page.items.len()); } #[tokio::test] async fn list_issues_parses_response_with_scope() { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { when.method(GET).path("/repos/octo/alpha/issues"); then.status(200).json_body(serde_json::json!([ { "number": 1, "title": "Bug A", "state": "open", "labels": [{"name": "bug"}], "user": {"login": "octo"} }, { "number": 2, "title": "Done", "state": "closed" } ])); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("t"), server.base_url()).unwrap(); let page = client .list_issues(IssueFilter { scope: Some("octo/alpha".to_string()), ..Default::default() }) .await .unwrap(); assert_eq!(page.items.len(), 2); assert_eq!(page.items[0].title, "Bug A"); assert_eq!(page.items[0].state, codenik_core::IssueState::Open); assert_eq!(page.items[0].labels, vec!["bug".to_string()]); assert_eq!(page.items[1].state, codenik_core::IssueState::Closed); } #[tokio::test] async fn list_issues_without_scope_returns_invalid_input() { let client = GitVerseClient::with_base_url(SecretString::from("t"), "http://127.0.0.1:1").unwrap(); let err = client .list_issues(IssueFilter::default()) .await .unwrap_err(); assert!(matches!(err, Error::InvalidInput(_))); } #[tokio::test] async fn get_issue_parses_response() { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { when.method(GET).path("/repos/octo/alpha/issues/42"); then.status(200).json_body(serde_json::json!({ "number": 42, "title": "Crash", "state": "open", "user": {"login": "octo"} })); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("t"), server.base_url()).unwrap(); let key = IssueKey::new("octo/alpha", "42"); let issue = client.get_issue(&key).await.unwrap(); assert_eq!(issue.title, "Crash"); assert_eq!(issue.key.id, "42"); } #[tokio::test] async fn add_comment_posts_body() { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { when.method(POST) .path("/repos/octo/alpha/issues/7/comments") .json_body(serde_json::json!({"body": "hi"})); then.status(201).json_body(serde_json::json!({ "id": 11, "body": "hi", "user": {"login": "octo"} })); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("t"), server.base_url()).unwrap(); let key = IssueKey::new("octo/alpha", "7"); let c = client.add_comment(&key, "hi").await.unwrap(); assert_eq!(c.id, "11"); assert_eq!(c.body, "hi"); } #[tokio::test] async fn list_pulls_parses_response() { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { when.method(GET).path("/repos/octo/alpha/pulls"); then.status(200).json_body(serde_json::json!([ { "number": 5, "title": "Add feature", "state": "open", "user": {"login": "octo"}, "head": {"ref": "feat/x", "sha": "1"}, "base": {"ref": "main", "sha": "0"} }, { "number": 6, "title": "Released", "state": "closed", "user": {"login": "octo"}, "head": {"ref": "feat/y"}, "base": {"ref": "main"}, "merged_at": "2026-05-01T00:00:00Z" } ])); }) .await; let client = GitVerseClient::with_base_url(SecretString::from("t"), server.base_url()).unwrap(); let page = client .list_merge_requests(MrFilter { scope: Some("octo/alpha".to_string()), ..Default::default() }) .await .unwrap(); assert_eq!(page.items.len(), 2); assert_eq!(page.items[0].source_branch, "feat/x"); assert_eq!(page.items[0].target_branch, "main"); assert_eq!(page.items[0].state, codenik_core::MergeRequestState::Open); assert_eq!(page.items[1].state, codenik_core::MergeRequestState::Merged); } /// Smoke list_issues: gitverse возвращает [] для нашего репо, проверяем парсинг пустого массива. #[tokio::test] #[ignore = "smoke test against real GitVerse API"] async fn smoke_list_issues_against_real_api() { let Ok(token) = std::env::var("GITVERSE_TOKEN") else { return; }; if token.is_empty() { return; } let client = GitVerseClient::new(SecretString::from(token)).unwrap(); let filter = IssueFilter { scope: Some("codenik/codenik-tools".to_string()), ..Default::default() }; let page = client.list_issues(filter).await.expect("list_issues failed"); eprintln!("smoke: list_issues returned {} item(s)", page.items.len()); } }