diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..560d6c6 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,502 @@ +use super::*; + +use std::collections::BTreeMap; +use std::fmt::Write; + +impl Forgejo { + pub async fn admin_get_crons(&self, query: CronQuery) -> Result, ForgejoError> { + self.get(&query.path()).await + } + + pub async fn admin_run_cron(&self, name: &str) -> Result<(), ForgejoError> { + self.post_unit(&format!("admin/cron/{name}"), &()).await + } + + pub async fn admin_get_emails( + &self, + query: EmailListQuery, + ) -> Result, ForgejoError> { + self.get(&query.path()).await + } + + pub async fn admin_search_emails( + &self, + query: EmailSearchQuery, + ) -> Result, ForgejoError> { + self.get(&query.path()).await + } + + pub async fn admin_get_hooks(&self, query: HookQuery) -> Result, ForgejoError> { + self.get(&query.path()).await + } + + pub async fn admin_create_hook(&self, opt: CreateHookOption) -> Result { + self.post("admin/hooks", &opt).await + } + + pub async fn admin_get_hook(&self, id: u64) -> Result, ForgejoError> { + self.get_opt(&format!("admin/hooks/{id}")).await + } + + pub async fn admin_delete_hook(&self, id: u64) -> Result<(), ForgejoError> { + self.delete(&format!("admin/hooks/{id}")).await + } + + pub async fn admin_edit_hook( + &self, + id: u64, + opt: EditHookOption, + ) -> Result { + self.patch(&format!("admin/hooks/{id}"), &opt).await + } + + pub async fn admin_get_orgs( + &self, + query: AdminOrganizationQuery, + ) -> Result, ForgejoError> { + self.get(&query.path()).await + } + + pub async fn admin_unadopted_repos( + &self, + query: UnadoptedRepoQuery, + ) -> Result, ForgejoError> { + self.get(&query.path()).await + } + + pub async fn admin_adopt(&self, owner: &str, repo: &str) -> Result<(), ForgejoError> { + self.post(&format!("admin/unadopted/{owner}/{repo}"), &()) + .await + } + + pub async fn admin_delete_unadopted( + &self, + owner: &str, + repo: &str, + ) -> Result<(), ForgejoError> { + self.delete(&format!("admin/unadopted/{owner}/{repo}")) + .await + } + + pub async fn admin_users(&self, query: AdminUserQuery) -> Result, ForgejoError> { + self.get(&query.path()).await + } + + pub async fn admin_create_user(&self, opt: CreateUserOption) -> Result { + self.post("admin/users", &opt).await + } + + pub async fn admin_delete_user(&self, user: &str, purge: bool) -> Result<(), ForgejoError> { + self.delete(&format!("admin/users/{user}?purge={purge}")) + .await + } + + pub async fn admin_edit_user( + &self, + user: &str, + opt: CreateUserOption, + ) -> Result { + self.patch(&format!("admin/users/{user}"), &opt).await + } + + pub async fn admin_add_key( + &self, + user: &str, + opt: CreateKeyOption, + ) -> Result { + self.post(&format!("admin/users/{user}/keys"), &opt).await + } + + pub async fn admin_delete_key(&self, user: &str, id: u64) -> Result<(), ForgejoError> { + self.delete(&format!("admin/users/{user}/keys/{id}")).await + } + + pub async fn admin_create_org( + &self, + owner: &str, + opt: CreateOrgOption, + ) -> Result { + self.post(&format!("admin/users/{owner}/orgs"), &opt).await + } + + pub async fn admin_rename_user( + &self, + user: &str, + opt: RenameUserOption, + ) -> Result<(), ForgejoError> { + self.post_unit(&format!("admin/users/{user}/rename"), &opt) + .await + } + + pub async fn admin_create_repo( + &self, + owner: &str, + opt: CreateRepoOption, + ) -> Result { + self.post(&format!("admin/users/{owner}/repos"), &opt).await + } +} + +#[derive(serde::Deserialize, Debug, PartialEq)] +pub struct Cron { + pub exec_times: u64, + pub name: String, + #[serde(with = "time::serde::rfc3339")] + pub next: time::OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub prev: time::OffsetDateTime, + pub schedule: String, +} + +#[derive(Default, Debug)] +pub struct CronQuery { + pub page: Option, + pub limit: Option, +} + +impl CronQuery { + fn path(&self) -> String { + let mut s = String::from("admin/cron?"); + if let Some(page) = self.page { + s.push_str("page="); + s.write_fmt(format_args!("{page}")) + .expect("writing to string can't fail"); + s.push('&'); + } + if let Some(limit) = self.limit { + s.push_str("limit="); + s.write_fmt(format_args!("{limit}")) + .expect("writing to string can't fail"); + s.push('&'); + } + s + } +} + +#[derive(serde::Deserialize, Debug, PartialEq)] +pub struct Email { + pub email: String, + pub primary: bool, + pub user_id: u64, + pub username: String, + pub verified: bool, +} + +#[derive(Default, Debug)] +pub struct EmailListQuery { + pub page: Option, + pub limit: Option, +} + +impl EmailListQuery { + fn path(&self) -> String { + let mut s = String::from("admin/emails?"); + if let Some(page) = self.page { + s.push_str("page="); + s.write_fmt(format_args!("{page}")) + .expect("writing to string can't fail"); + s.push('&'); + } + if let Some(limit) = self.limit { + s.push_str("limit="); + s.write_fmt(format_args!("{limit}")) + .expect("writing to string can't fail"); + s.push('&'); + } + s + } +} + +#[derive(Default, Debug)] +pub struct EmailSearchQuery { + pub query: String, + pub page: Option, + pub limit: Option, +} + +impl EmailSearchQuery { + fn path(&self) -> String { + let mut s = String::from("admin/emails/search?"); + if !self.query.is_empty() { + s.push_str("q="); + s.push_str(&self.query); + s.push('&'); + } + if let Some(page) = self.page { + s.push_str("page="); + s.write_fmt(format_args!("{page}")) + .expect("writing to string can't fail"); + s.push('&'); + } + if let Some(limit) = self.limit { + s.push_str("limit="); + s.write_fmt(format_args!("{limit}")) + .expect("writing to string can't fail"); + s.push('&'); + } + s + } +} + +#[derive(serde::Deserialize, Debug, PartialEq)] +pub struct Hook { + pub active: bool, + pub authorization_header: String, + pub branch_filter: String, + pub config: std::collections::BTreeMap, + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, + pub events: Vec, + pub id: u64, + #[serde(rename = "type")] + pub _type: HookType, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: time::OffsetDateTime, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] +#[non_exhaustive] +#[serde(rename_all = "lowercase")] +pub enum HookType { + Forgejo, + Dingtalk, + Discord, + Gitea, + Gogs, + Msteams, + Slack, + Telegram, + Feishu, + Wechatwork, + Packagist, +} + +#[derive(Default, Debug)] +pub struct HookQuery { + pub page: Option, + pub limit: Option, +} + +impl HookQuery { + fn path(&self) -> String { + let mut s = String::from("admin/hooks?"); + if let Some(page) = self.page { + s.push_str("page="); + s.write_fmt(format_args!("{page}")) + .expect("writing to string can't fail"); + s.push('&'); + } + if let Some(limit) = self.limit { + s.push_str("limit="); + s.write_fmt(format_args!("{limit}")) + .expect("writing to string can't fail"); + s.push('&'); + } + s + } +} + +#[derive(serde::Serialize, Debug, PartialEq)] +pub struct CreateHookOption { + pub active: Option, + pub authorization_header: Option, + pub branch_filter: Option, + pub config: CreateHookOptionConfig, + pub events: Vec, + #[serde(rename = "type")] + pub _type: HookType, +} + +#[derive(serde::Serialize, Debug, PartialEq)] +pub struct CreateHookOptionConfig { + pub content_type: String, + pub url: Url, + #[serde(flatten)] + pub other: BTreeMap, +} + +#[derive(serde::Serialize, Debug, PartialEq, Default)] +pub struct EditHookOption { + pub active: Option, + pub authorization_header: Option, + pub branch_filter: Option, + pub config: Option>, + pub events: Option>, +} + +#[derive(Default, Debug)] +pub struct AdminOrganizationQuery { + pub page: Option, + pub limit: Option, +} + +impl AdminOrganizationQuery { + fn path(&self) -> String { + let mut s = String::from("admin/orgs?"); + if let Some(page) = self.page { + s.push_str("page="); + s.write_fmt(format_args!("{page}")) + .expect("writing to string can't fail"); + s.push('&'); + } + if let Some(limit) = self.limit { + s.push_str("limit="); + s.write_fmt(format_args!("{limit}")) + .expect("writing to string can't fail"); + s.push('&'); + } + s + } +} + +#[derive(Default, Debug)] +pub struct UnadoptedRepoQuery { + pub page: Option, + pub limit: Option, + pub pattern: String, +} + +impl UnadoptedRepoQuery { + fn path(&self) -> String { + let mut s = String::from("admin/unadopted?"); + if let Some(page) = self.page { + s.push_str("page="); + s.write_fmt(format_args!("{page}")) + .expect("writing to string can't fail"); + s.push('&'); + } + if let Some(limit) = self.limit { + s.push_str("limit="); + s.write_fmt(format_args!("{limit}")) + .expect("writing to string can't fail"); + s.push('&'); + } + if !self.pattern.is_empty() { + s.push_str("pattern="); + s.push_str(&self.pattern); + s.push('&'); + } + s + } +} + +#[derive(Default, Debug)] +pub struct AdminUserQuery { + pub source_id: Option, + pub login_name: String, + pub page: Option, + pub limit: Option, +} + +impl AdminUserQuery { + fn path(&self) -> String { + let mut s = String::from("admin/users?"); + if let Some(source_id) = self.source_id { + s.push_str("source_id="); + s.write_fmt(format_args!("{source_id}")) + .expect("writing to string can't fail"); + s.push('&'); + } + if !self.login_name.is_empty() { + s.push_str("login_name="); + s.push_str(&self.login_name); + s.push('&'); + } + if let Some(page) = self.page { + s.push_str("page="); + s.write_fmt(format_args!("{page}")) + .expect("writing to string can't fail"); + s.push('&'); + } + if let Some(limit) = self.limit { + s.push_str("limit="); + s.write_fmt(format_args!("{limit}")) + .expect("writing to string can't fail"); + s.push('&'); + } + s + } +} + +#[derive(serde::Serialize, Debug, PartialEq)] +pub struct CreateUserOption { + #[serde(with = "time::serde::rfc3339::option")] + pub created_at: Option, + pub email: String, + pub full_name: Option, + pub login_name: Option, + pub must_change_password: bool, + pub password: String, + pub restricted: bool, + pub send_notify: bool, + pub source_id: Option, + pub username: String, + pub visibility: String, +} + +#[derive(serde::Serialize, Debug, PartialEq, Default)] +pub struct EditUserOption { + pub active: Option, + pub admin: Option, + pub allow_create_organization: Option, + pub allow_git_hook: Option, + pub allow_import_local: Option, + pub description: Option, + pub email: Option, + pub full_name: Option, + pub location: Option, + pub login_name: Option, + pub max_repo_creation: Option, + pub must_change_password: Option, + pub password: Option, + pub prohibit_login: Option, + pub restricted: Option, + pub source_id: Option, + pub visibility: Option, + pub website: Option, +} + +#[derive(serde::Serialize, Debug, PartialEq)] +pub struct CreateKeyOption { + pub key: String, + pub read_only: Option, + pub title: String, +} + +#[derive(serde::Deserialize, Debug, PartialEq)] +pub struct PublicKey { + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, + pub fingerprint: String, + pub id: u64, + pub key: String, + pub key_type: String, + pub read_only: Option, + pub title: String, + pub url: Option, + pub user: User, +} + +#[derive(serde::Serialize, Debug, PartialEq)] +pub struct CreateOrgOption { + pub description: Option, + pub full_name: Option, + pub location: Option, + pub repo_admin_change_team_access: Option, + pub username: String, + pub visibility: OrgVisibility, + pub website: Option, +} + +#[derive(serde::Serialize, Debug, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum OrgVisibility { + Public, + Limited, + Private, +} + +#[derive(serde::Serialize, Debug, PartialEq)] +pub struct RenameUserOption { + pub new_username: String, +} diff --git a/src/lib.rs b/src/lib.rs index d9e8179..21b0a45 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub struct Forgejo { client: Client, } +mod admin; mod issue; mod misc; mod notification; @@ -16,6 +17,7 @@ mod package; mod repository; mod user; +pub use admin::*; pub use issue::*; pub use misc::*; pub use notification::*; diff --git a/tests/ci_test.rs b/tests/ci_test.rs index b949b7f..39acbd4 100644 --- a/tests/ci_test.rs +++ b/tests/ci_test.rs @@ -11,6 +11,7 @@ async fn ci() -> eyre::Result<()> { results.push(user(&api).await.wrap_err("user error")); results.push(repo(&api).await.wrap_err("repo error")); + results.push(admin(&api).await.wrap_err("admin error")); let mut errors = 0; for report in results.into_iter().filter_map(Result::err) { @@ -233,3 +234,128 @@ async fn repo(api: &forgejo_api::Forgejo) -> eyre::Result<()> { Ok(()) } + +async fn admin(api: &forgejo_api::Forgejo) -> eyre::Result<()> { + let user_opt = forgejo_api::CreateUserOption { + created_at: None, + email: "user@noreply.example.org".into(), + full_name: None, + login_name: None, + must_change_password: false, + password: "userpass".into(), + restricted: false, + send_notify: true, + source_id: None, + username: "Pipis".into(), + visibility: "public".into(), + }; + let _ = api + .admin_create_user(user_opt) + .await + .wrap_err("failed to create user")?; + + let users = api + .admin_users(forgejo_api::AdminUserQuery::default()) + .await + .wrap_err("failed to search users")?; + ensure!( + users.iter().find(|u| u.login == "Pipis").is_some(), + "could not find new user" + ); + let users = api + .admin_get_emails(forgejo_api::EmailListQuery::default()) + .await + .wrap_err("failed to search emails")?; + ensure!( + users + .iter() + .find(|u| u.email == "user@noreply.example.org") + .is_some(), + "could not find new user" + ); + + let org_opt = forgejo_api::CreateOrgOption { + description: None, + full_name: None, + location: None, + repo_admin_change_team_access: None, + username: "test-org".into(), + visibility: forgejo_api::OrgVisibility::Public, + website: None, + }; + let _ = api + .admin_create_org("Pipis", org_opt) + .await + .wrap_err("failed to create org")?; + ensure!( + !api.admin_get_orgs(forgejo_api::AdminOrganizationQuery::default()) + .await? + .is_empty(), + "org list empty" + ); + + let key_opt = forgejo_api::CreateKeyOption { + key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN68ehQAsbGEwlXPa2AxbAh1QxFQrtRel2jeC0hRlPc1 user@noreply.example.org".into(), + read_only: None, + title: "Example Key".into(), + }; + let key = api + .admin_add_key("Pipis", key_opt) + .await + .wrap_err("failed to create key")?; + api.admin_delete_key("Pipis", key.id) + .await + .wrap_err("failed to delete key")?; + + let rename_opt = forgejo_api::RenameUserOption { + new_username: "Bepis".into(), + }; + api.admin_rename_user("Pipis", rename_opt) + .await + .wrap_err("failed to rename user")?; + api.admin_delete_user("Bepis", true) + .await + .wrap_err("failed to delete user")?; + ensure!( + api.admin_delete_user("Ghost", true).await.is_err(), + "deleting fake user should fail" + ); + + let crons = api + .admin_get_crons(forgejo_api::CronQuery::default()) + .await + .wrap_err("failed to get crons list")?; + api.admin_run_cron(&crons.get(0).ok_or_else(|| eyre!("no crons"))?.name) + .await + .wrap_err("failed to run cron")?; + + let hook_opt = forgejo_api::CreateHookOption { + active: None, + authorization_header: None, + branch_filter: None, + config: forgejo_api::CreateHookOptionConfig { + content_type: "json".into(), + url: url::Url::parse("http://test.local/").unwrap(), + other: Default::default(), + }, + events: Vec::new(), + _type: forgejo_api::HookType::Forgejo, + }; + // yarr har har me matey this is me hook + let hook = api + .admin_create_hook(hook_opt) + .await + .wrap_err("failed to create hook")?; + let edit_hook = forgejo_api::EditHookOption { + active: Some(true), + ..Default::default() + }; + api.admin_edit_hook(hook.id, edit_hook) + .await + .wrap_err("failed to edit hook")?; + api.admin_delete_hook(hook.id) + .await + .wrap_err("failed to delete hook")?; + + Ok(()) +}