use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::api::client::{ApiError, GitHub};
use crate::api::model::{Capped, NodeId, RepoId};
use crate::api::{has_scope, raw, scope_missing};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SettingsPage {
General,
Branches,
Access,
Hooks,
Hook(u64),
Keys,
}
impl SettingsPage {
pub const MENU: [SettingsPage; 5] = [
SettingsPage::General,
SettingsPage::Access,
SettingsPage::Branches,
SettingsPage::Hooks,
SettingsPage::Keys,
];
pub fn slug(self) -> String {
match self {
SettingsPage::General => String::new(),
SettingsPage::Branches => "/branches".into(),
SettingsPage::Access => "/access".into(),
SettingsPage::Hooks => "/hooks".into(),
SettingsPage::Hook(id) => format!("/hooks/{id}"),
SettingsPage::Keys => "/keys".into(),
}
}
pub fn name(self) -> &'static str {
match self {
SettingsPage::General => "General",
SettingsPage::Branches => "Branches",
SettingsPage::Access => "Collaborators",
SettingsPage::Hooks => "Webhooks",
SettingsPage::Hook(_) => "Webhook",
SettingsPage::Keys => "Deploy keys",
}
}
pub fn menu(self) -> SettingsPage {
match self {
SettingsPage::Hook(_) => SettingsPage::Hooks,
page => page,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Listed<T> {
pub items: Vec<T>,
pub more: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Settings {
General(General),
Branches(Branches),
Access(Access),
Hooks(Listed<Hook>),
Hook(Box<HookPage>),
Keys(Listed<DeployKey>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct General {
pub archived: bool,
pub visibility: String,
pub default_branch: String,
pub admin: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Branches {
pub repo_id: NodeId,
pub rules: Capped<Rule>,
pub rulesets: Result<Vec<Ruleset>, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
pub id: NodeId,
pub form: RuleForm,
pub apps: Vec<(String, NodeId)>,
pub matching: Capped<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuleForm {
pub pattern: String,
pub reviews: Option<Reviews>,
pub checks: Option<Checks>,
pub conversations: bool,
pub signed: bool,
pub linear: bool,
pub lock: bool,
pub admins: bool,
pub force_pushes: bool,
pub deletions: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Reviews {
pub count: u8,
pub dismiss_stale: bool,
pub code_owners: bool,
pub last_push: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Checks {
pub strict: bool,
pub contexts: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ruleset {
pub id: u64,
pub name: String,
pub target: String,
pub enforcement: Enforcement,
pub org: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Enforcement {
Active,
Disabled,
Evaluate,
}
impl Enforcement {
pub fn word(self) -> &'static str {
match self {
Enforcement::Active => "active",
Enforcement::Disabled => "disabled",
Enforcement::Evaluate => "evaluate",
}
}
pub fn label(self) -> &'static str {
match self {
Enforcement::Active => "Active",
Enforcement::Disabled => "Disabled",
Enforcement::Evaluate => "Evaluate",
}
}
fn parse(word: &str) -> Self {
match word {
"active" => Enforcement::Active,
"evaluate" => Enforcement::Evaluate,
_ => Enforcement::Disabled,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Access {
pub people: Listed<Collaborator>,
pub invitations: Listed<Invitation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Collaborator {
pub login: String,
pub role: Role,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Role {
Is(Permission),
Custom(String),
}
impl Role {
pub fn name(&self) -> &str {
match self {
Role::Is(p) => p.name(),
Role::Custom(name) => name,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Invitation {
pub id: u64,
pub login: String,
pub permission: Permission,
pub created_at: String,
pub expired: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Permission {
Read,
Triage,
Write,
Maintain,
Admin,
}
impl Permission {
pub const ALL: [Permission; 5] = [
Permission::Read,
Permission::Triage,
Permission::Write,
Permission::Maintain,
Permission::Admin,
];
pub fn name(self) -> &'static str {
match self {
Permission::Read => "Read",
Permission::Triage => "Triage",
Permission::Write => "Write",
Permission::Maintain => "Maintain",
Permission::Admin => "Admin",
}
}
pub fn about(self) -> &'static str {
match self {
Permission::Read => "read and clone; open and comment on issues and pull requests",
Permission::Triage => "read, and manage issues and pull requests",
Permission::Write => "read, clone and push",
Permission::Maintain => "push, and manage the repository without its settings",
Permission::Admin => "everything, settings and deleting it included",
}
}
fn wire(self) -> &'static str {
match self {
Permission::Read => "pull",
Permission::Triage => "triage",
Permission::Write => "push",
Permission::Maintain => "maintain",
Permission::Admin => "admin",
}
}
pub fn parse(word: &str) -> Option<Self> {
Some(match word {
"read" | "pull" => Permission::Read,
"triage" => Permission::Triage,
"write" | "push" => Permission::Write,
"maintain" => Permission::Maintain,
"admin" => Permission::Admin,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hook {
pub id: u64,
pub form: HookForm,
pub secret: bool,
pub last: Option<LastResponse>,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookForm {
pub url: String,
pub content_type: String,
pub secret: Option<String>,
pub verify_ssl: bool,
pub events: Vec<String>,
pub active: bool,
}
impl Default for HookForm {
fn default() -> Self {
Self {
url: String::new(),
content_type: "json".into(),
secret: None,
verify_ssl: true,
events: vec!["push".into()],
active: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LastResponse {
pub code: Option<u16>,
pub status: String,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookPage {
pub hook: Hook,
pub deliveries: Listed<Delivery>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Delivery {
pub id: u64,
pub guid: String,
pub delivered_at: String,
pub redelivery: bool,
pub status: String,
pub code: u16,
pub event: String,
pub action: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployKey {
pub id: u64,
pub title: String,
pub key: String,
pub read_only: bool,
pub created_at: String,
pub last_used: Option<String>,
pub added_by: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SettingsChange {
CreateRule {
repo_id: NodeId,
form: RuleForm,
},
UpdateRule {
id: NodeId,
from: Box<RuleForm>,
to: Box<RuleForm>,
apps: Vec<(String, NodeId)>,
},
DeleteRule {
id: NodeId,
pattern: String,
},
SetRuleset {
repo: RepoId,
id: u64,
name: String,
enforcement: Enforcement,
},
SetCollaborator {
repo: RepoId,
login: String,
permission: Permission,
invite: bool,
},
RemoveCollaborator {
repo: RepoId,
login: String,
},
SetInvitation {
repo: RepoId,
id: u64,
login: String,
permission: Permission,
},
CancelInvitation {
repo: RepoId,
id: u64,
login: String,
},
CreateHook {
repo: RepoId,
form: HookForm,
},
UpdateHook {
repo: RepoId,
id: u64,
from: Box<HookForm>,
to: Box<HookForm>,
},
DeleteHook {
repo: RepoId,
id: u64,
url: String,
},
Redeliver {
repo: RepoId,
hook: u64,
delivery: u64,
guid: String,
},
AddKey {
repo: RepoId,
title: String,
key: String,
write: bool,
},
DeleteKey {
repo: RepoId,
id: u64,
title: String,
},
Archive {
repo: RepoId,
archived: bool,
},
}
const PER_PAGE: usize = 100;
fn refused(status: u16, scopes: Option<&str>, what: &str, scope: &str) -> Option<String> {
if !matches!(status, 403 | 404) {
return None;
}
let lacks = scopes.is_some_and(|s| !has_scope(s, "repo") && !has_scope(s, scope));
Some(if lacks {
scope_missing(what, scope)
} else {
format!("GitHub shows {what} only to the repository's admins")
})
}
impl GitHub {
pub async fn settings(&self, repo: &RepoId, page: SettingsPage) -> Result<Settings, ApiError> {
let settings = match page {
SettingsPage::General => Settings::General(self.general(repo).await?),
SettingsPage::Branches => Settings::Branches(self.protection_rules(repo).await?),
SettingsPage::Access => Settings::Access(Access {
people: self
.listed(
repo,
"collaborators?affiliation=direct&",
"collaborators",
wire::person,
)
.await?,
invitations: self
.listed(repo, "invitations?", "invitations", wire::invitation)
.await?,
}),
SettingsPage::Hooks => {
Settings::Hooks(self.listed(repo, "hooks?", "webhooks", wire::hook).await?)
}
SettingsPage::Hook(id) => {
let path = format!("/repos/{repo}/hooks/{id}");
let answer = self.admin_answer(&path, "webhooks").await?;
let hook = wire::hook(serde_json::from_str(answer.body())?)
.ok_or_else(|| ApiError::Decode(format!("{repo}'s webhook {id}")))?;
let deliveries = self
.listed(
repo,
&format!("hooks/{id}/deliveries?"),
"webhooks",
wire::delivery,
)
.await?;
Settings::Hook(Box::new(HookPage { hook, deliveries }))
}
SettingsPage::Keys => {
Settings::Keys(self.listed(repo, "keys?", "deploy keys", wire::key).await?)
}
};
Ok(settings)
}
async fn admin_answer(
&self,
path: &str,
what: &str,
) -> Result<crate::api::client::Answer, ApiError> {
let scope = if what == "webhooks" {
"admin:repo_hook"
} else {
"repo"
};
let answer = self.rest_refusable(path, what, |status, _, answer| {
refused(status, answer.header("x-oauth-scopes"), what, scope)
});
answer.await?.map_err(ApiError::Denied)
}
async fn listed<W: serde::de::DeserializeOwned, T>(
&self,
repo: &RepoId,
list: &str,
what: &str,
read: fn(W) -> Option<T>,
) -> Result<Listed<T>, ApiError> {
let path = format!("/repos/{repo}/{list}per_page={PER_PAGE}");
let answer = self.admin_answer(&path, what).await?;
let wire: Vec<W> = serde_json::from_str(answer.body())?;
Ok(Listed {
more: answer.next_page().is_some(),
items: wire.into_iter().filter_map(read).collect(),
})
}
async fn general(&self, repo: &RepoId) -> Result<General, ApiError> {
#[derive(Deserialize)]
struct Wire {
archived: bool,
#[serde(default)]
visibility: Option<String>,
#[serde(default)]
private: bool,
#[serde(default)]
default_branch: String,
#[serde(default)]
permissions: Option<Perms>,
}
#[derive(Deserialize)]
struct Perms {
#[serde(default)]
admin: bool,
}
let path = format!("/repos/{repo}");
let wire: Wire = serde_json::from_str(&self.rest_get(&path, repo).await?)?;
Ok(General {
archived: wire.archived,
visibility: wire
.visibility
.unwrap_or_else(|| if wire.private { "private" } else { "public" }.into()),
default_branch: wire.default_branch,
admin: wire.permissions.is_some_and(|p| p.admin),
})
}
async fn protection_rules(&self, repo: &RepoId) -> Result<Branches, ApiError> {
let vars = json!({ "owner": repo.owner, "name": repo.name });
let found: wire::Rules = self
.graphql_json(raw::PROTECTION_RULES, vars, repo, "/repository")
.await?;
let repo_id = NodeId::new(found.id);
let rules = (found.branch_protection_rules).into_capped(|n| Some(wire::rule(n)));
let rulesets = match self
.admin_answer(
&format!("/repos/{repo}/rulesets?per_page={PER_PAGE}"),
"rulesets",
)
.await
{
Ok(answer) => {
let wire: Vec<wire::RulesetWire> = serde_json::from_str(answer.body())?;
Ok(wire.into_iter().map(|r| wire::ruleset(r, repo)).collect())
}
Err(ApiError::NotFound(_)) => Err("GitHub doesn't have rulesets here".into()),
Err(ApiError::Denied(why)) => Err(why),
Err(err) => return Err(err),
};
Ok(Branches {
repo_id,
rules,
rulesets,
})
}
pub(crate) async fn change_setting(&self, change: &SettingsChange) -> Result<(), ApiError> {
use SettingsChange as C;
use http::Method;
match change {
C::CreateRule { repo_id, form } => {
let mut input = rule_input(&RuleForm::default(), form, &[], true);
input.insert("repositoryId".into(), repo_id.as_str().into());
let vars = json!({ "input": Value::Object(input) });
self.mutate_json(raw::CREATE_PROTECTION_RULE, vars).await
}
C::UpdateRule { id, from, to, apps } => {
let mut input = rule_input(from, to, apps, false);
input.insert("branchProtectionRuleId".into(), id.as_str().into());
let vars = json!({ "input": Value::Object(input) });
self.mutate_json(raw::UPDATE_PROTECTION_RULE, vars).await
}
C::DeleteRule { id, .. } => {
let vars = json!({ "id": id.as_str() });
self.mutate_json(raw::DELETE_PROTECTION_RULE, vars).await
}
C::SetRuleset {
repo,
id,
enforcement,
..
} => {
let body = json!({ "enforcement": enforcement.word() });
let path = format!("/repos/{repo}/rulesets/{id}");
self.rest_write(Method::PUT, &path, Some(&body)).await
}
C::SetCollaborator {
repo,
login,
permission,
..
} => {
let body = json!({ "permission": permission.wire() });
let path = format!("/repos/{repo}/collaborators/{login}");
self.rest_write(Method::PUT, &path, Some(&body)).await
}
C::RemoveCollaborator { repo, login } => {
let path = format!("/repos/{repo}/collaborators/{login}");
self.rest_write(Method::DELETE, &path, None).await
}
C::SetInvitation {
repo,
id,
permission,
..
} => {
let body = json!({ "permissions": permission.name().to_lowercase() });
let path = format!("/repos/{repo}/invitations/{id}");
self.rest_write(Method::PATCH, &path, Some(&body)).await
}
C::CancelInvitation { repo, id, .. } => {
let path = format!("/repos/{repo}/invitations/{id}");
self.rest_write(Method::DELETE, &path, None).await
}
C::CreateHook { repo, form } => {
let body = hook_body(None, form);
let path = format!("/repos/{repo}/hooks");
self.rest_write(Method::POST, &path, Some(&body)).await
}
C::UpdateHook { repo, id, from, to } => {
let body = hook_body(Some(from), to);
let path = format!("/repos/{repo}/hooks/{id}");
self.rest_write(Method::PATCH, &path, Some(&body)).await
}
C::DeleteHook { repo, id, .. } => {
let path = format!("/repos/{repo}/hooks/{id}");
self.rest_write(Method::DELETE, &path, None).await
}
C::Redeliver {
repo,
hook,
delivery,
..
} => {
let path = format!("/repos/{repo}/hooks/{hook}/deliveries/{delivery}/attempts");
self.rest_write(Method::POST, &path, None).await
}
C::AddKey {
repo,
title,
key,
write,
} => {
let body = json!({ "title": title, "key": key, "read_only": !write });
let path = format!("/repos/{repo}/keys");
self.rest_write(Method::POST, &path, Some(&body)).await
}
C::DeleteKey { repo, id, .. } => {
let path = format!("/repos/{repo}/keys/{id}");
self.rest_write(Method::DELETE, &path, None).await
}
C::Archive { repo, archived } => {
let body = json!({ "archived": archived });
let path = format!("/repos/{repo}");
self.rest_write(Method::PATCH, &path, Some(&body)).await
}
}
}
}
type Flag = fn(&RuleForm) -> bool;
fn rule_input(
from: &RuleForm,
to: &RuleForm,
apps: &[(String, NodeId)],
all: bool,
) -> Map<String, Value> {
let mut input = Map::new();
let mut put = |name: &str, old: Value, new: Value| {
if all || old != new {
input.insert(name.to_owned(), new);
}
};
put(
"pattern",
from.pattern.clone().into(),
to.pattern.clone().into(),
);
let reviews = |f: &RuleForm| f.reviews.clone();
let (old, new) = (reviews(from), reviews(to));
put(
"requiresApprovingReviews",
old.is_some().into(),
new.is_some().into(),
);
let (old, new) = (old.unwrap_or_default(), new.unwrap_or_default());
put(
"requiredApprovingReviewCount",
old.count.into(),
new.count.into(),
);
put(
"dismissesStaleReviews",
old.dismiss_stale.into(),
new.dismiss_stale.into(),
);
put(
"requiresCodeOwnerReviews",
old.code_owners.into(),
new.code_owners.into(),
);
put(
"requireLastPushApproval",
old.last_push.into(),
new.last_push.into(),
);
let (old, new) = (from.checks.clone(), to.checks.clone());
put(
"requiresStatusChecks",
old.is_some().into(),
new.is_some().into(),
);
let (old, new) = (old.unwrap_or_default(), new.unwrap_or_default());
put(
"requiresStrictStatusChecks",
old.strict.into(),
new.strict.into(),
);
let checks = |c: &Checks| -> Value {
c.contexts
.iter()
.map(|context| {
let app = apps.iter().find(|(name, _)| name == context);
match app {
Some((_, id)) => json!({ "context": context, "appId": id.as_str() }),
None => json!({ "context": context }),
}
})
.collect()
};
put("requiredStatusChecks", checks(&old), checks(&new));
let flags: [(&str, Flag); 7] = [
("requiresConversationResolution", |f| f.conversations),
("requiresCommitSignatures", |f| f.signed),
("requiresLinearHistory", |f| f.linear),
("lockBranch", |f| f.lock),
("isAdminEnforced", |f| f.admins),
("allowsForcePushes", |f| f.force_pushes),
("allowsDeletions", |f| f.deletions),
];
for (name, flag) in flags {
put(name, flag(from).into(), flag(to).into());
}
input
}
fn hook_body(from: Option<&HookForm>, to: &HookForm) -> Value {
let mut body = Map::new();
let mut config = Map::new();
let changed = |f: fn(&HookForm) -> Value| from.is_none_or(|from| f(from) != f(to));
if changed(|h| h.url.clone().into()) {
config.insert("url".into(), to.url.clone().into());
}
if changed(|h| h.content_type.clone().into()) {
config.insert("content_type".into(), to.content_type.clone().into());
}
if changed(|h| h.verify_ssl.into()) {
let insecure = if to.verify_ssl { "0" } else { "1" };
config.insert("insecure_ssl".into(), insecure.into());
}
if let Some(secret) = &to.secret {
config.insert("secret".into(), secret.clone().into());
}
if from.is_none() {
body.insert("name".into(), "web".into());
}
if !config.is_empty() {
if let Some(from) = from {
config
.entry("url")
.or_insert_with(|| from.url.clone().into());
config
.entry("content_type")
.or_insert_with(|| from.content_type.clone().into());
let insecure = if from.verify_ssl { "0" } else { "1" };
config
.entry("insecure_ssl")
.or_insert_with(|| insecure.into());
}
body.insert("config".into(), Value::Object(config));
}
if changed(|h| h.events.clone().into()) {
body.insert("events".into(), to.events.clone().into());
}
if changed(|h| h.active.into()) {
body.insert("active".into(), to.active.into());
}
Value::Object(body)
}
mod wire {
use serde::Deserialize;
use super::*;
use crate::api::browse::wire::{Counted, Name};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct Rules {
pub id: String,
pub branch_protection_rules: Counted<RuleNode>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub(super) struct RuleNode {
id: String,
pattern: String,
requires_approving_reviews: bool,
required_approving_review_count: Option<u64>,
dismisses_stale_reviews: bool,
requires_code_owner_reviews: bool,
require_last_push_approval: bool,
requires_status_checks: bool,
requires_strict_status_checks: bool,
required_status_checks: Option<Vec<Check>>,
requires_conversation_resolution: bool,
requires_commit_signatures: bool,
requires_linear_history: bool,
lock_branch: bool,
is_admin_enforced: bool,
allows_force_pushes: bool,
allows_deletions: bool,
matching_refs: Option<Counted<Name>>,
}
#[derive(Deserialize, Default)]
struct Check {
context: String,
app: Option<App>,
}
#[derive(Deserialize, Default)]
struct App {
id: String,
}
pub(super) fn rule(n: RuleNode) -> Rule {
let checks = n.required_status_checks.unwrap_or_default();
let apps = (checks.iter())
.filter_map(|c| Some((c.context.clone(), NodeId::new(&c.app.as_ref()?.id))))
.collect();
let contexts = checks.into_iter().map(|c| c.context).collect();
let matching =
(n.matching_refs).map_or_else(Capped::default, |m| m.into_capped(|r| Some(r.name)));
Rule {
id: NodeId::new(n.id),
form: RuleForm {
pattern: n.pattern,
reviews: n.requires_approving_reviews.then(|| Reviews {
count: u8::try_from(n.required_approving_review_count.unwrap_or_default())
.unwrap_or(u8::MAX),
dismiss_stale: n.dismisses_stale_reviews,
code_owners: n.requires_code_owner_reviews,
last_push: n.require_last_push_approval,
}),
checks: n.requires_status_checks.then_some(Checks {
strict: n.requires_strict_status_checks,
contexts,
}),
conversations: n.requires_conversation_resolution,
signed: n.requires_commit_signatures,
linear: n.requires_linear_history,
lock: n.lock_branch,
admins: n.is_admin_enforced,
force_pushes: n.allows_force_pushes,
deletions: n.allows_deletions,
},
apps,
matching,
}
}
#[derive(Deserialize)]
pub(super) struct RulesetWire {
id: u64,
#[serde(default)]
name: String,
target: Option<String>,
#[serde(default)]
enforcement: String,
#[serde(default)]
source: String,
#[serde(default)]
source_type: String,
}
pub(super) fn ruleset(r: RulesetWire, repo: &RepoId) -> Ruleset {
let theirs =
r.source_type == "Organization" || !r.source.eq_ignore_ascii_case(&repo.to_string());
Ruleset {
id: r.id,
name: r.name,
target: r.target.unwrap_or_else(|| "branch".into()),
enforcement: Enforcement::parse(&r.enforcement),
org: Some(r.source).filter(|s| theirs && !s.is_empty()),
}
}
#[derive(Deserialize)]
pub(super) struct Person {
login: String,
#[serde(default)]
role_name: String,
}
#[expect(clippy::unnecessary_wraps, reason = "read as every list's items are")]
pub(super) fn person(p: Person) -> Option<Collaborator> {
Some(Collaborator {
login: p.login,
role: Permission::parse(&p.role_name).map_or(Role::Custom(p.role_name), Role::Is),
})
}
#[derive(Deserialize)]
pub(super) struct InvitationWire {
id: u64,
invitee: Option<crate::api::browse::UserLogin>,
#[serde(default)]
permissions: String,
#[serde(default)]
created_at: String,
#[serde(default)]
expired: bool,
}
pub(super) fn invitation(i: InvitationWire) -> Option<Invitation> {
Some(Invitation {
id: i.id,
login: i.invitee?.login,
permission: Permission::parse(&i.permissions)?,
created_at: i.created_at,
expired: i.expired,
})
}
#[derive(Deserialize)]
pub(super) struct HookWire {
id: u64,
#[serde(default)]
config: Config,
#[serde(default)]
events: Vec<String>,
active: Option<bool>,
last_response: Option<Last>,
#[serde(default)]
updated_at: String,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct Config {
url: String,
content_type: Option<String>,
insecure_ssl: Option<String>,
secret: Option<String>,
}
#[derive(Deserialize)]
struct Last {
code: Option<u16>,
#[serde(default)]
status: String,
message: Option<String>,
}
#[expect(clippy::unnecessary_wraps, reason = "read as every list's items are")]
pub(super) fn hook(h: HookWire) -> Option<Hook> {
let c = h.config;
Some(Hook {
id: h.id,
form: HookForm {
url: c.url,
content_type: c.content_type.unwrap_or_else(|| "form".into()),
secret: None,
verify_ssl: c.insecure_ssl.as_deref().unwrap_or("0") == "0",
events: h.events,
active: h.active.unwrap_or(true),
},
secret: c.secret.is_some_and(|s| !s.is_empty()),
last: (h.last_response)
.filter(|l| !l.status.is_empty() && l.status != "unused")
.map(|l| LastResponse {
code: l.code,
status: l.status,
message: l.message.filter(|m| !m.is_empty()),
}),
updated_at: h.updated_at,
})
}
#[derive(Deserialize)]
pub(super) struct DeliveryWire {
id: u64,
#[serde(default)]
guid: String,
#[serde(default)]
delivered_at: String,
#[serde(default)]
redelivery: bool,
#[serde(default)]
status: String,
#[serde(default)]
status_code: u16,
#[serde(default)]
event: String,
action: Option<String>,
}
#[expect(clippy::unnecessary_wraps, reason = "read as every list's items are")]
pub(super) fn delivery(d: DeliveryWire) -> Option<Delivery> {
Some(Delivery {
id: d.id,
guid: d.guid,
delivered_at: d.delivered_at,
redelivery: d.redelivery,
status: d.status,
code: d.status_code,
event: d.event,
action: d.action,
})
}
#[derive(Deserialize)]
pub(super) struct KeyWire {
id: u64,
#[serde(default)]
title: String,
#[serde(default)]
key: String,
read_only: Option<bool>,
#[serde(default)]
created_at: String,
last_used: Option<String>,
added_by: Option<String>,
}
#[expect(clippy::unnecessary_wraps, reason = "read as every list's items are")]
pub(super) fn key(k: KeyWire) -> Option<DeployKey> {
Some(DeployKey {
id: k.id,
title: k.title,
key: k.key,
read_only: k.read_only.unwrap_or(true),
created_at: k.created_at,
last_used: k.last_used,
added_by: k.added_by,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_edited_rule_sends_only_what_changed() {
let from = RuleForm {
pattern: "main".into(),
reviews: Some(Reviews {
count: 1,
..Reviews::default()
}),
checks: Some(Checks {
strict: true,
contexts: vec!["check".into()],
}),
admins: true,
..RuleForm::default()
};
let mut to = from.clone();
if let Some(r) = &mut to.reviews {
r.count = 2;
}
if let Some(c) = &mut to.checks {
c.contexts.push("lint".into());
}
let apps = [("check".to_owned(), NodeId::new("A_1"))];
let input = Value::Object(rule_input(&from, &to, &apps, false));
assert_eq!(
input,
json!({
"requiredApprovingReviewCount": 2,
"requiredStatusChecks": [{"context": "check", "appId": "A_1"}, {"context": "lint"}],
})
);
}
#[test]
fn a_rules_input_is_githubs() {
let sdl = include_str!(env!("GITHUB_SDL"));
let input = rule_input(&RuleForm::default(), &RuleForm::default(), &[], true);
for kind in [
"CreateBranchProtectionRuleInput",
"UpdateBranchProtectionRuleInput",
] {
let (_, after) = sdl
.split_once(&format!("input {kind} "))
.unwrap_or_default();
let (fields, _) = after.split_once("\n}").unwrap_or_default();
for name in input.keys() {
assert!(
fields.contains(&format!(" {name}:")),
"{kind} has no {name}"
);
}
}
}
#[test]
fn an_edited_hook_sends_what_changed() {
let from = HookForm {
url: "https://example.com/a".into(),
..HookForm::default()
};
let to = HookForm {
active: false,
..from.clone()
};
assert_eq!(hook_body(Some(&from), &to), json!({"active": false}));
let to = HookForm {
url: "https://example.com/b".into(),
secret: Some("s3".into()),
..from.clone()
};
assert_eq!(
hook_body(Some(&from), &to),
json!({"config": {
"url": "https://example.com/b", "secret": "s3",
"content_type": "json", "insecure_ssl": "0",
}})
);
}
#[test]
fn a_refusal_names_what_is_missing() {
let why = refused(404, Some("repo, gist"), "webhooks", "admin:repo_hook");
assert_eq!(
why.as_deref(),
Some("GitHub shows webhooks only to the repository's admins")
);
let why = refused(404, Some("gist"), "webhooks", "admin:repo_hook").unwrap_or_default();
assert!(
why.contains("`gh auth refresh -s admin:repo_hook`"),
"{why}"
);
assert_eq!(refused(500, None, "webhooks", "admin:repo_hook"), None);
}
}