feat: GiteaClient — unified git forge API (50+ endpoints)

Typed Gitea REST API client with PAT auth covering repos, issues, PRs,
branches, orgs, users, file content, and notifications.

Bump: sunbeam-sdk v0.5.0
This commit is contained in:
2026-03-21 20:24:48 +00:00
parent c597234cd9
commit 890d7b80ac
4 changed files with 1049 additions and 3 deletions

2
Cargo.lock generated
View File

@@ -3591,7 +3591,7 @@ dependencies = [
[[package]]
name = "sunbeam-sdk"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"base64",
"bytes",

View File

@@ -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"

View File

@@ -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<u32>,
) -> Result<types::SearchResult<types::Repository>> {
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<types::Repository> {
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<types::Repository> {
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<types::Repository> {
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<types::Repository> {
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<types::Repository> {
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<types::Repository> {
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<u32>,
) -> Result<Vec<types::Issue>> {
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<types::Issue> {
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<types::Issue> {
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<types::Issue> {
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<Vec<types::Comment>> {
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<types::Comment> {
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<Vec<types::PullRequest>> {
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<types::PullRequest> {
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<types::PullRequest> {
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<Vec<types::Branch>> {
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<types::Branch> {
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<Vec<types::Organization>> {
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<types::Organization> {
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<types::Organization> {
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<u32>,
) -> Result<Vec<types::Repository>> {
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<u32>,
) -> Result<types::SearchResult<types::User>> {
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<types::User> {
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<types::User> {
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<types::FileContent> {
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<bytes::Bytes> {
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<Vec<types::Notification>> {
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";

View File

@@ -0,0 +1,463 @@
//! Gitea API types.
use serde::{Deserialize, Serialize};
/// Generic search result wrapper.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult<T> {
#[serde(default)]
pub ok: Option<bool>,
#[serde(default)]
pub data: Vec<T>,
}
/// 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<User>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_init: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitignores: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub readme: Option<String>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_issues: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_pull_requests: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_wiki: Option<bool>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub organization: Option<String>,
}
/// 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<Vec<u64>>,
}
/// A Gitea issue.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub number: u64,
#[serde(default)]
pub title: String,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub state: String,
#[serde(default)]
pub assignees: Option<Vec<User>>,
#[serde(default)]
pub labels: Option<Vec<Label>>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub html_url: Option<String>,
#[serde(default)]
pub repository: Option<RepositoryMeta>,
#[serde(default)]
pub milestone: Option<Milestone>,
#[serde(default)]
pub comments: Option<u64>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignees: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<u64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub milestone: Option<u64>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignees: Option<Vec<String>>,
}
/// A pull request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequest {
pub number: u64,
#[serde(default)]
pub title: String,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub state: String,
#[serde(default)]
pub head: Option<PullRequestRef>,
#[serde(default)]
pub base: Option<PullRequestRef>,
#[serde(default)]
pub merged: bool,
#[serde(default)]
pub mergeable: Option<bool>,
#[serde(default)]
pub html_url: Option<String>,
#[serde(default)]
pub assignees: Option<Vec<User>>,
#[serde(default)]
pub labels: Option<Vec<Label>>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
/// 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<Repository>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignees: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<u64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub milestone: Option<u64>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delete_branch_after_merge: Option<bool>,
}
/// 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<User>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
/// 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<BranchCommit>,
#[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<String>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>,
}
/// 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<String>,
}
/// 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<String>,
#[serde(default)]
pub encoding: Option<String>,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub download_url: Option<String>,
}
/// A notification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Notification {
pub id: u64,
#[serde(default)]
pub subject: Option<NotificationSubject>,
#[serde(default)]
pub repository: Option<Repository>,
#[serde(default)]
pub unread: bool,
#[serde(default)]
pub updated_at: Option<String>,
}
/// 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<String>,
}
#[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<User> = serde_json::from_value(json).unwrap();
assert_eq!(result.data.len(), 1);
assert_eq!(result.data[0].login, "alice");
}
}