use chrono::{DateTime, Utc}; use reqwest::StatusCode; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; pub type GiteaResult = Result; #[derive(Debug)] pub enum GiteaError { Http(reqwest::Error), Api { status: StatusCode, body: String }, } impl std::fmt::Display for GiteaError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Http(err) => write!(f, "http error: {err}"), Self::Api { status, body } => write!(f, "gitea api error {status}: {body}"), } } } impl std::error::Error for GiteaError {} impl From for GiteaError { fn from(value: reqwest::Error) -> Self { Self::Http(value) } } #[derive(Debug, Clone)] pub struct GiteaClient { http: reqwest::Client, api_base: String, token: Option, } impl GiteaClient { pub fn new(instance_base_url: impl Into) -> Self { let base = instance_base_url.into().trim_end_matches('/').to_owned(); let api_base = if base.ends_with("/api/v1") { base } else { format!("{base}/api/v1") }; Self { http: reqwest::Client::new(), api_base, token: None, } } pub fn with_token(mut self, token: impl Into) -> Self { self.token = Some(token.into()); self } pub fn with_http_client(mut self, http: reqwest::Client) -> Self { self.http = http; self } pub async fn list_pull_requests( &self, owner: &str, repo: &str, query: &ListPullRequestsQuery, ) -> GiteaResult> { let path = format!("/repos/{owner}/{repo}/pulls"); self.get_json(&path, query).await } pub async fn get_pull_request( &self, owner: &str, repo: &str, index: i64, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/pulls/{index}"); self.get_json_no_query(&path).await } pub async fn edit_pull_request( &self, owner: &str, repo: &str, index: i64, body: &EditPullRequestOption, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/pulls/{index}"); self.send_json(self.request(reqwest::Method::PATCH, &path).json(body)) .await } pub async fn list_pull_request_commits( &self, owner: &str, repo: &str, index: i64, query: &ListPullRequestCommitsQuery, ) -> GiteaResult> { let path = format!("/repos/{owner}/{repo}/pulls/{index}/commits"); self.get_json(&path, query).await } pub async fn list_pull_request_files( &self, owner: &str, repo: &str, index: i64, query: &ListPullRequestFilesQuery, ) -> GiteaResult> { let path = format!("/repos/{owner}/{repo}/pulls/{index}/files"); self.get_json(&path, query).await } pub async fn list_pull_reviews( &self, owner: &str, repo: &str, index: i64, query: &ListPullReviewsQuery, ) -> GiteaResult> { let path = format!("/repos/{owner}/{repo}/pulls/{index}/reviews"); self.get_json(&path, query).await } pub async fn create_pull_review( &self, owner: &str, repo: &str, index: i64, body: &CreatePullReviewOptions, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/pulls/{index}/reviews"); self.send_json(self.request(reqwest::Method::POST, &path).json(body)) .await } pub async fn get_pull_review( &self, owner: &str, repo: &str, index: i64, review_id: i64, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/pulls/{index}/reviews/{review_id}"); self.get_json_no_query(&path).await } pub async fn submit_pull_review( &self, owner: &str, repo: &str, index: i64, review_id: i64, body: &SubmitPullReviewOptions, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/pulls/{index}/reviews/{review_id}"); self.send_json(self.request(reqwest::Method::POST, &path).json(body)) .await } pub async fn delete_pull_review( &self, owner: &str, repo: &str, index: i64, review_id: i64, ) -> GiteaResult<()> { let path = format!("/repos/{owner}/{repo}/pulls/{index}/reviews/{review_id}"); self.send_empty(self.request(reqwest::Method::DELETE, &path)) .await } pub async fn dismiss_pull_review( &self, owner: &str, repo: &str, index: i64, review_id: i64, body: &DismissPullReviewOptions, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/pulls/{index}/reviews/{review_id}/dismissals"); self.send_json(self.request(reqwest::Method::POST, &path).json(body)) .await } pub async fn undismiss_pull_review( &self, owner: &str, repo: &str, index: i64, review_id: i64, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/pulls/{index}/reviews/{review_id}/undismissals"); self.send_json(self.request(reqwest::Method::POST, &path)) .await } pub async fn list_pull_review_comments( &self, owner: &str, repo: &str, index: i64, review_id: i64, ) -> GiteaResult> { let path = format!("/repos/{owner}/{repo}/pulls/{index}/reviews/{review_id}/comments"); self.get_json_no_query(&path).await } pub async fn list_issue_comments( &self, owner: &str, repo: &str, index: i64, query: &ListIssueCommentsQuery, ) -> GiteaResult> { let path = format!("/repos/{owner}/{repo}/issues/{index}/comments"); self.get_json(&path, query).await } pub async fn create_issue_comment( &self, owner: &str, repo: &str, index: i64, body: &CreateIssueCommentOption, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/issues/{index}/comments"); self.send_json(self.request(reqwest::Method::POST, &path).json(body)) .await } pub async fn get_issue_comment( &self, owner: &str, repo: &str, comment_id: i64, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/issues/comments/{comment_id}"); self.get_json_no_query(&path).await } pub async fn edit_issue_comment( &self, owner: &str, repo: &str, comment_id: i64, body: &EditIssueCommentOption, ) -> GiteaResult { let path = format!("/repos/{owner}/{repo}/issues/comments/{comment_id}"); self.send_json(self.request(reqwest::Method::PATCH, &path).json(body)) .await } pub async fn delete_issue_comment( &self, owner: &str, repo: &str, comment_id: i64, ) -> GiteaResult<()> { let path = format!("/repos/{owner}/{repo}/issues/comments/{comment_id}"); self.send_empty(self.request(reqwest::Method::DELETE, &path)) .await } fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { let mut req = self .http .request(method, format!("{}{}", self.api_base, path)); if let Some(token) = &self.token { req = req.header("Authorization", format!("token {token}")); } req } async fn get_json(&self, path: &str, query: &Q) -> GiteaResult where Q: Serialize + ?Sized, T: DeserializeOwned, { let query_string = serde_urlencoded::to_string(query).map_err(|err| GiteaError::Api { status: StatusCode::BAD_REQUEST, body: format!("failed to encode query parameters: {err}"), })?; let full_path = if query_string.is_empty() { path.to_owned() } else { format!("{path}?{query_string}") }; self.send_json(self.request(reqwest::Method::GET, &full_path)) .await } async fn get_json_no_query(&self, path: &str) -> GiteaResult where T: DeserializeOwned, { self.send_json(self.request(reqwest::Method::GET, path)) .await } async fn send_json(&self, req: reqwest::RequestBuilder) -> GiteaResult where T: DeserializeOwned, { let resp = req.send().await?; if resp.status().is_success() { return Ok(resp.json::().await?); } let status = resp.status(); let body = resp.text().await.unwrap_or_default(); Err(GiteaError::Api { status, body }) } async fn send_empty(&self, req: reqwest::RequestBuilder) -> GiteaResult<()> { let resp = req.send().await?; if resp.status().is_success() { return Ok(()); } let status = resp.status(); let body = resp.text().await.unwrap_or_default(); Err(GiteaError::Api { status, body }) } } #[derive(Debug, Clone, Default, Serialize)] pub struct ListPullRequestsQuery { #[serde(skip_serializing_if = "Option::is_none")] pub base_branch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub state: Option, #[serde(skip_serializing_if = "Option::is_none")] pub sort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub milestone: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub labels: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub poster: Option, #[serde(skip_serializing_if = "Option::is_none")] pub page: Option, #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, } #[derive(Debug, Clone, Default, Serialize)] pub struct ListPullRequestCommitsQuery { #[serde(skip_serializing_if = "Option::is_none")] pub page: Option, #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub verification: Option, #[serde(skip_serializing_if = "Option::is_none")] pub files: Option, } #[derive(Debug, Clone, Default, Serialize)] pub struct ListPullRequestFilesQuery { #[serde(rename = "skip-to", skip_serializing_if = "Option::is_none")] pub skip_to: Option, #[serde(skip_serializing_if = "Option::is_none")] pub whitespace: Option, #[serde(skip_serializing_if = "Option::is_none")] pub page: Option, #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, } #[derive(Debug, Clone, Default, Serialize)] pub struct ListPullReviewsQuery { #[serde(skip_serializing_if = "Option::is_none")] pub page: Option, #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, } #[derive(Debug, Clone, Default, Serialize)] pub struct ListIssueCommentsQuery { #[serde(skip_serializing_if = "Option::is_none")] pub since: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub before: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { pub id: Option, pub login: Option, pub full_name: Option, pub email: Option, pub avatar_url: Option, pub html_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Label { pub id: Option, pub name: Option, pub color: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Milestone { pub id: Option, pub title: Option, pub state: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Team { pub id: Option, pub name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrBranchInfo { #[serde(flatten)] pub extra: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PullRequest { pub id: Option, pub number: Option, pub title: Option, pub body: Option, pub state: Option, pub draft: Option, pub merged: Option, pub mergeable: Option, pub merge_base: Option, pub merge_commit_sha: Option, pub additions: Option, pub deletions: Option, pub changed_files: Option, pub comments: Option, pub review_comments: Option, pub html_url: Option, pub diff_url: Option, pub patch_url: Option, pub url: Option, pub created_at: Option>, pub updated_at: Option>, pub closed_at: Option>, pub merged_at: Option>, pub due_date: Option>, pub assignee: Option, pub assignees: Option>, pub user: Option, pub merged_by: Option, pub base: Option, pub head: Option, pub labels: Option>, pub milestone: Option, pub requested_reviewers: Option>, pub requested_reviewers_teams: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChangedFile { pub additions: Option, pub changes: Option, pub contents_url: Option, pub deletions: Option, pub filename: Option, pub html_url: Option, pub previous_filename: Option, pub raw_url: Option, pub status: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Commit { pub sha: Option, pub html_url: Option, pub url: Option, pub author: Option, pub committer: Option, #[serde(default)] pub files: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommitAffectedFile { pub filename: Option, pub status: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PullReview { pub id: Option, pub body: Option, pub state: Option, pub commit_id: Option, pub comments_count: Option, pub dismissed: Option, pub stale: Option, pub official: Option, pub html_url: Option, pub pull_request_url: Option, pub submitted_at: Option>, pub updated_at: Option>, pub user: Option, pub team: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PullReviewComment { pub id: Option, pub body: Option, pub path: Option, pub position: Option, pub original_position: Option, pub commit_id: Option, pub original_commit_id: Option, pub diff_hunk: Option, pub pull_request_review_id: Option, pub pull_request_url: Option, pub html_url: Option, pub created_at: Option>, pub updated_at: Option>, pub user: Option, pub resolver: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Comment { pub id: Option, pub body: Option, pub html_url: Option, pub issue_url: Option, pub pull_request_url: Option, pub original_author: Option, pub original_author_id: Option, pub created_at: Option>, pub updated_at: Option>, pub user: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReviewState(pub String); impl ReviewState { pub fn approve() -> Self { Self("APPROVE".to_owned()) } pub fn request_changes() -> Self { Self("REQUEST_CHANGES".to_owned()) } pub fn comment() -> Self { Self("COMMENT".to_owned()) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreatePullReviewComment { pub body: Option, pub path: Option, pub new_position: Option, pub old_position: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreatePullReviewOptions { pub body: Option, pub comments: Option>, pub commit_id: Option, pub event: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubmitPullReviewOptions { pub body: Option, pub event: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DismissPullReviewOptions { pub message: Option, pub priors: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateIssueCommentOption { pub body: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EditIssueCommentOption { pub body: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CreatePullRequestOption { pub title: Option, pub body: Option, pub head: Option, pub base: Option, pub assignee: Option, pub assignees: Option>, pub labels: Option>, pub milestone: Option, pub reviewers: Option>, pub team_reviewers: Option>, pub due_date: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct EditPullRequestOption { pub title: Option, pub body: Option, pub base: Option, pub state: Option, pub assignee: Option, pub assignees: Option>, pub labels: Option>, pub milestone: Option, pub due_date: Option>, pub unset_due_date: Option, pub allow_maintainer_edit: Option, }