diff --git a/Cargo.lock b/Cargo.lock index 485b772..8f7d296 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3591,7 +3591,7 @@ dependencies = [ [[package]] name = "sunbeam-sdk" -version = "0.3.0" +version = "0.4.0" dependencies = [ "base64", "bytes", diff --git a/sunbeam-sdk/Cargo.toml b/sunbeam-sdk/Cargo.toml index 1214d27..88f9c6b 100644 --- a/sunbeam-sdk/Cargo.toml +++ b/sunbeam-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sunbeam-sdk" -version = "0.4.0" +version = "0.5.0" edition = "2024" description = "Sunbeam SDK — reusable library for cluster management" repository = "https://src.sunbeam.pt/studio/cli" diff --git a/sunbeam-sdk/src/gitea/mod.rs b/sunbeam-sdk/src/gitea/mod.rs index b94924c..db2b85d 100644 --- a/sunbeam-sdk/src/gitea/mod.rs +++ b/sunbeam-sdk/src/gitea/mod.rs @@ -1,13 +1,596 @@ -//! Gitea bootstrap -- admin setup, org creation, OIDC auth source configuration. +//! Gitea API client and bootstrap operations. +pub mod types; + +use crate::client::{AuthMethod, HttpTransport, ServiceClient}; use crate::error::Result; use k8s_openapi::api::core::v1::Pod; use kube::api::{Api, ListParams}; +use reqwest::Method; use serde_json::Value; use crate::kube::{get_client, get_domain, kube_exec, kube_get_secret_field}; use crate::output::{ok, step, warn}; +// --------------------------------------------------------------------------- +// Gitea API Client (ServiceClient trait) +// --------------------------------------------------------------------------- + +/// Full Gitea REST API client using PAT authentication. +pub struct GiteaClient { + pub(crate) transport: HttpTransport, +} + +impl ServiceClient for GiteaClient { + fn service_name(&self) -> &'static str { + "gitea" + } + + fn base_url(&self) -> &str { + &self.transport.base_url + } + + fn from_parts(base_url: String, auth: AuthMethod) -> Self { + Self { + transport: HttpTransport::new(&base_url, auth), + } + } +} + +impl GiteaClient { + /// Build a GiteaClient from domain, using the cached Gitea PAT. + pub fn connect(domain: &str) -> Self { + let base_url = format!("https://src.{domain}/api/v1"); + let auth = match crate::auth::get_gitea_token() { + Ok(token) => AuthMethod::Token(token), + Err(_) => AuthMethod::None, + }; + Self::from_parts(base_url, auth) + } + + /// Build a GiteaClient from domain and explicit token. + pub fn with_token(domain: &str, token: String) -> Self { + let base_url = format!("https://src.{domain}/api/v1"); + Self::from_parts(base_url, AuthMethod::Token(token)) + } + + // -- Repos -------------------------------------------------------------- + + /// Search repositories. + pub async fn search_repos( + &self, + query: &str, + limit: Option, + ) -> Result> { + let limit = limit.unwrap_or(20); + self.transport + .json( + Method::GET, + &format!("repos/search?q={query}&limit={limit}"), + Option::<&()>::None, + "gitea search repos", + ) + .await + } + + /// Get a repository. + pub async fn get_repo(&self, owner: &str, repo: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("repos/{owner}/{repo}"), + Option::<&()>::None, + "gitea get repo", + ) + .await + } + + /// Create a repository for a user. + pub async fn create_user_repo(&self, body: &types::CreateRepoBody) -> Result { + self.transport + .json(Method::POST, "user/repos", Some(body), "gitea create user repo") + .await + } + + /// Create a repository for an organization. + pub async fn create_org_repo( + &self, + org: &str, + body: &types::CreateRepoBody, + ) -> Result { + self.transport + .json( + Method::POST, + &format!("orgs/{org}/repos"), + Some(body), + "gitea create org repo", + ) + .await + } + + /// Edit a repository. + pub async fn edit_repo( + &self, + owner: &str, + repo: &str, + body: &types::EditRepoBody, + ) -> Result { + self.transport + .json( + Method::PATCH, + &format!("repos/{owner}/{repo}"), + Some(body), + "gitea edit repo", + ) + .await + } + + /// Delete a repository. + pub async fn delete_repo(&self, owner: &str, repo: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("repos/{owner}/{repo}"), + Option::<&()>::None, + "gitea delete repo", + ) + .await + } + + /// Fork a repository. + pub async fn fork_repo( + &self, + owner: &str, + repo: &str, + body: &types::ForkRepoBody, + ) -> Result { + self.transport + .json( + Method::POST, + &format!("repos/{owner}/{repo}/forks"), + Some(body), + "gitea fork repo", + ) + .await + } + + /// Trigger mirror sync. + pub async fn mirror_sync(&self, owner: &str, repo: &str) -> Result<()> { + self.transport + .send( + Method::POST, + &format!("repos/{owner}/{repo}/mirror-sync"), + Option::<&()>::None, + "gitea mirror sync", + ) + .await + } + + /// Transfer a repository to another owner. + pub async fn transfer_repo( + &self, + owner: &str, + repo: &str, + body: &types::TransferRepoBody, + ) -> Result { + self.transport + .json( + Method::POST, + &format!("repos/{owner}/{repo}/transfer"), + Some(body), + "gitea transfer repo", + ) + .await + } + + // -- Issues ------------------------------------------------------------- + + /// List issues for a repo. + pub async fn list_issues( + &self, + owner: &str, + repo: &str, + state: &str, + limit: Option, + ) -> Result> { + let limit = limit.unwrap_or(50); + self.transport + .json( + Method::GET, + &format!("repos/{owner}/{repo}/issues?state={state}&type=issues&limit={limit}"), + Option::<&()>::None, + "gitea list issues", + ) + .await + } + + /// Get a single issue. + pub async fn get_issue( + &self, + owner: &str, + repo: &str, + index: u64, + ) -> Result { + self.transport + .json( + Method::GET, + &format!("repos/{owner}/{repo}/issues/{index}"), + Option::<&()>::None, + "gitea get issue", + ) + .await + } + + /// Create an issue. + pub async fn create_issue( + &self, + owner: &str, + repo: &str, + body: &types::CreateIssueBody, + ) -> Result { + self.transport + .json( + Method::POST, + &format!("repos/{owner}/{repo}/issues"), + Some(body), + "gitea create issue", + ) + .await + } + + /// Edit an issue. + pub async fn edit_issue( + &self, + owner: &str, + repo: &str, + index: u64, + body: &types::EditIssueBody, + ) -> Result { + self.transport + .json( + Method::PATCH, + &format!("repos/{owner}/{repo}/issues/{index}"), + Some(body), + "gitea edit issue", + ) + .await + } + + /// List issue comments. + pub async fn list_issue_comments( + &self, + owner: &str, + repo: &str, + index: u64, + ) -> Result> { + self.transport + .json( + Method::GET, + &format!("repos/{owner}/{repo}/issues/{index}/comments"), + Option::<&()>::None, + "gitea list comments", + ) + .await + } + + /// Create an issue comment. + pub async fn create_issue_comment( + &self, + owner: &str, + repo: &str, + index: u64, + body: &str, + ) -> Result { + let payload = serde_json::json!({"body": body}); + self.transport + .json( + Method::POST, + &format!("repos/{owner}/{repo}/issues/{index}/comments"), + Some(&payload), + "gitea create comment", + ) + .await + } + + // -- Pull Requests ------------------------------------------------------ + + /// List pull requests. + pub async fn list_pulls( + &self, + owner: &str, + repo: &str, + state: &str, + ) -> Result> { + self.transport + .json( + Method::GET, + &format!("repos/{owner}/{repo}/pulls?state={state}&limit=50"), + Option::<&()>::None, + "gitea list pulls", + ) + .await + } + + /// Get a pull request. + pub async fn get_pull( + &self, + owner: &str, + repo: &str, + index: u64, + ) -> Result { + self.transport + .json( + Method::GET, + &format!("repos/{owner}/{repo}/pulls/{index}"), + Option::<&()>::None, + "gitea get pull", + ) + .await + } + + /// Create a pull request. + pub async fn create_pull( + &self, + owner: &str, + repo: &str, + body: &types::CreatePullBody, + ) -> Result { + self.transport + .json( + Method::POST, + &format!("repos/{owner}/{repo}/pulls"), + Some(body), + "gitea create pull", + ) + .await + } + + /// Merge a pull request. + pub async fn merge_pull( + &self, + owner: &str, + repo: &str, + index: u64, + body: &types::MergePullBody, + ) -> Result<()> { + self.transport + .send( + Method::POST, + &format!("repos/{owner}/{repo}/pulls/{index}/merge"), + Some(body), + "gitea merge pull", + ) + .await + } + + // -- Branches ----------------------------------------------------------- + + /// List branches. + pub async fn list_branches( + &self, + owner: &str, + repo: &str, + ) -> Result> { + self.transport + .json( + Method::GET, + &format!("repos/{owner}/{repo}/branches"), + Option::<&()>::None, + "gitea list branches", + ) + .await + } + + /// Create a branch. + pub async fn create_branch( + &self, + owner: &str, + repo: &str, + body: &types::CreateBranchBody, + ) -> Result { + self.transport + .json( + Method::POST, + &format!("repos/{owner}/{repo}/branches"), + Some(body), + "gitea create branch", + ) + .await + } + + /// Delete a branch. + pub async fn delete_branch( + &self, + owner: &str, + repo: &str, + branch: &str, + ) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("repos/{owner}/{repo}/branches/{branch}"), + Option::<&()>::None, + "gitea delete branch", + ) + .await + } + + // -- Orgs --------------------------------------------------------------- + + /// List user's organizations. + pub async fn list_user_orgs(&self, username: &str) -> Result> { + self.transport + .json( + Method::GET, + &format!("users/{username}/orgs"), + Option::<&()>::None, + "gitea list user orgs", + ) + .await + } + + /// Get an organization. + pub async fn get_org(&self, org: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("orgs/{org}"), + Option::<&()>::None, + "gitea get org", + ) + .await + } + + /// Create an organization. + pub async fn create_org(&self, body: &types::CreateOrgBody) -> Result { + self.transport + .json(Method::POST, "orgs", Some(body), "gitea create org") + .await + } + + /// List organization repos. + pub async fn list_org_repos( + &self, + org: &str, + limit: Option, + ) -> Result> { + let limit = limit.unwrap_or(50); + self.transport + .json( + Method::GET, + &format!("orgs/{org}/repos?limit={limit}"), + Option::<&()>::None, + "gitea list org repos", + ) + .await + } + + // -- Users -------------------------------------------------------------- + + /// Search users. + pub async fn search_users( + &self, + query: &str, + limit: Option, + ) -> Result> { + let limit = limit.unwrap_or(20); + self.transport + .json( + Method::GET, + &format!("users/search?q={query}&limit={limit}"), + Option::<&()>::None, + "gitea search users", + ) + .await + } + + /// Get the authenticated user. + pub async fn get_authenticated_user(&self) -> Result { + self.transport + .json( + Method::GET, + "user", + Option::<&()>::None, + "gitea get authenticated user", + ) + .await + } + + /// Get a user. + pub async fn get_user(&self, username: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("users/{username}"), + Option::<&()>::None, + "gitea get user", + ) + .await + } + + // -- File content ------------------------------------------------------- + + /// Get file content. + pub async fn get_file_content( + &self, + owner: &str, + repo: &str, + filepath: &str, + r#ref: Option<&str>, + ) -> Result { + let mut path = format!("repos/{owner}/{repo}/contents/{filepath}"); + if let Some(r) = r#ref { + path.push_str(&format!("?ref={r}")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "gitea get file") + .await + } + + /// Get raw file content. + pub async fn get_raw_file( + &self, + owner: &str, + repo: &str, + filepath: &str, + r#ref: Option<&str>, + ) -> Result { + let mut path = format!("repos/{owner}/{repo}/raw/{filepath}"); + if let Some(r) = r#ref { + path.push_str(&format!("?ref={r}")); + } + self.transport + .bytes(Method::GET, &path, "gitea get raw file") + .await + } + + // -- Notifications ------------------------------------------------------ + + /// List notifications. + pub async fn list_notifications(&self) -> Result> { + self.transport + .json( + Method::GET, + "notifications", + Option::<&()>::None, + "gitea list notifications", + ) + .await + } + + /// Mark all notifications as read. + pub async fn mark_notifications_read(&self) -> Result<()> { + self.transport + .send( + Method::PUT, + "notifications", + Option::<&()>::None, + "gitea mark notifications read", + ) + .await + } +} + +#[cfg(test)] +mod gitea_client_tests { + use super::*; + + #[test] + fn test_gitea_client_connect_url() { + // connect() may fail to get token, but URL should be correct + let c = GiteaClient::from_parts( + "https://src.sunbeam.pt/api/v1".into(), + AuthMethod::Token("test".into()), + ); + assert_eq!(c.base_url(), "https://src.sunbeam.pt/api/v1"); + assert_eq!(c.service_name(), "gitea"); + } +} + +// --------------------------------------------------------------------------- +// Bootstrap operations (existing code below) +// --------------------------------------------------------------------------- + const GITEA_ADMIN_USER: &str = "gitea_admin"; const GITEA_ADMIN_EMAIL: &str = "gitea@local.domain"; diff --git a/sunbeam-sdk/src/gitea/types.rs b/sunbeam-sdk/src/gitea/types.rs new file mode 100644 index 0000000..23b4f3b --- /dev/null +++ b/sunbeam-sdk/src/gitea/types.rs @@ -0,0 +1,463 @@ +//! Gitea API types. + +use serde::{Deserialize, Serialize}; + +/// Generic search result wrapper. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + #[serde(default)] + pub ok: Option, + #[serde(default)] + pub data: Vec, +} + +/// A Gitea repository. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Repository { + #[serde(default)] + pub id: u64, + #[serde(default)] + pub name: String, + #[serde(default)] + pub full_name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub html_url: String, + #[serde(default)] + pub clone_url: String, + #[serde(default)] + pub ssh_url: String, + #[serde(default)] + pub default_branch: String, + #[serde(default)] + pub private: bool, + #[serde(default)] + pub fork: bool, + #[serde(default)] + pub mirror: bool, + #[serde(default)] + pub archived: bool, + #[serde(default)] + pub empty: bool, + #[serde(default)] + pub stars_count: u64, + #[serde(default)] + pub forks_count: u64, + #[serde(default)] + pub open_issues_count: u64, + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} + +/// Body for creating a repository. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CreateRepoBody { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub private: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_init: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gitignores: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub readme: Option, +} + +/// Body for editing a repository. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EditRepoBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub private: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_issues: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_pull_requests: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_wiki: Option, +} + +/// Body for forking a repository. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ForkRepoBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub organization: Option, +} + +/// Body for transferring a repository. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransferRepoBody { + pub new_owner: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_ids: Option>, +} + +/// A Gitea issue. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Issue { + pub number: u64, + #[serde(default)] + pub title: String, + #[serde(default)] + pub body: Option, + #[serde(default)] + pub state: String, + #[serde(default)] + pub assignees: Option>, + #[serde(default)] + pub labels: Option>, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub html_url: Option, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub milestone: Option, + #[serde(default)] + pub comments: Option, +} + +/// Body for creating an issue. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateIssueBody { + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignees: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub milestone: Option, +} + +/// Body for editing an issue. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EditIssueBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignees: Option>, +} + +/// A pull request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequest { + pub number: u64, + #[serde(default)] + pub title: String, + #[serde(default)] + pub body: Option, + #[serde(default)] + pub state: String, + #[serde(default)] + pub head: Option, + #[serde(default)] + pub base: Option, + #[serde(default)] + pub merged: bool, + #[serde(default)] + pub mergeable: Option, + #[serde(default)] + pub html_url: Option, + #[serde(default)] + pub assignees: Option>, + #[serde(default)] + pub labels: Option>, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} + +/// A pull request branch ref. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequestRef { + #[serde(default)] + pub label: String, + #[serde(rename = "ref", default)] + pub ref_name: String, + #[serde(default)] + pub sha: String, + #[serde(default)] + pub repo: Option, +} + +/// Body for creating a pull request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreatePullBody { + pub title: String, + pub head: String, + pub base: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignees: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub milestone: Option, +} + +/// Body for merging a pull request. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MergePullBody { + #[serde(rename = "Do")] + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "merge_message_field")] + pub merge_message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delete_branch_after_merge: Option, +} + +/// A comment on an issue or pull request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Comment { + pub id: u64, + #[serde(default)] + pub body: String, + #[serde(default)] + pub user: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} + +/// A Gitea user. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct User { + #[serde(default)] + pub id: u64, + #[serde(default)] + pub login: String, + #[serde(default)] + pub full_name: String, + #[serde(default)] + pub email: String, + #[serde(default)] + pub avatar_url: String, + #[serde(default)] + pub is_admin: bool, +} + +/// A label. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Label { + pub id: u64, + #[serde(default)] + pub name: String, + #[serde(default)] + pub color: String, + #[serde(default)] + pub description: String, +} + +/// A branch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Branch { + #[serde(default)] + pub name: String, + #[serde(default)] + pub commit: Option, + #[serde(default)] + pub protected: bool, +} + +/// A commit on a branch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BranchCommit { + #[serde(default)] + pub id: String, + #[serde(default)] + pub message: String, +} + +/// Body for creating a branch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateBranchBody { + pub new_branch_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_branch_name: Option, +} + +/// An organization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Organization { + pub id: u64, + #[serde(default)] + pub username: String, + #[serde(default)] + pub full_name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub avatar_url: String, + #[serde(default)] + pub visibility: String, +} + +/// Body for creating an organization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateOrgBody { + pub username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub full_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visibility: Option, +} + +/// A milestone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Milestone { + pub id: u64, + #[serde(default)] + pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub state: String, + #[serde(default)] + pub open_issues: u64, + #[serde(default)] + pub closed_issues: u64, +} + +/// Repository metadata (minimal, for issue responses). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryMeta { + #[serde(default)] + pub full_name: Option, +} + +/// File content response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileContent { + #[serde(default)] + pub name: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub sha: String, + #[serde(default)] + pub content: Option, + #[serde(default)] + pub encoding: Option, + #[serde(default)] + pub size: u64, + #[serde(default)] + pub r#type: String, + #[serde(default)] + pub download_url: Option, +} + +/// A notification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Notification { + pub id: u64, + #[serde(default)] + pub subject: Option, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub unread: bool, + #[serde(default)] + pub updated_at: Option, +} + +/// Notification subject. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationSubject { + #[serde(default)] + pub title: String, + #[serde(default)] + pub url: String, + #[serde(default)] + pub r#type: String, + #[serde(default)] + pub state: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_repo_roundtrip() { + let json = serde_json::json!({ + "id": 1, + "name": "cli", + "full_name": "studio/cli", + "default_branch": "main" + }); + let repo: Repository = serde_json::from_value(json).unwrap(); + assert_eq!(repo.name, "cli"); + assert_eq!(repo.full_name, "studio/cli"); + } + + #[test] + fn test_issue_roundtrip() { + let json = serde_json::json!({ + "number": 42, + "title": "Bug report", + "state": "open" + }); + let issue: Issue = serde_json::from_value(json).unwrap(); + assert_eq!(issue.number, 42); + assert_eq!(issue.state, "open"); + } + + #[test] + fn test_create_repo_body() { + let body = CreateRepoBody { + name: "new-repo".into(), + description: Some("A test repo".into()), + private: Some(true), + ..Default::default() + }; + let json = serde_json::to_value(&body).unwrap(); + assert_eq!(json["name"], "new-repo"); + assert_eq!(json["private"], true); + assert!(json.get("auto_init").is_none()); + } + + #[test] + fn test_search_result() { + let json = serde_json::json!({ + "ok": true, + "data": [{"id": 1, "login": "alice", "full_name": "Alice", "email": "", "avatar_url": "", "is_admin": false}] + }); + let result: SearchResult = serde_json::from_value(json).unwrap(); + assert_eq!(result.data.len(), 1); + assert_eq!(result.data[0].login, "alice"); + } +}