use std::collections::BTreeMap;
use std::fmt;
use reqwest::Method;
use serde::Deserialize;
use serde_json::{Value, json};
use url::Url;
use vgi_forge::{ForgeError, Result};
use crate::api::{Api, Auth};
use crate::secret::Secret;
pub const APP_PERMISSIONS: [(&str, &str); 9] = [
("actions_variables", "write"),
("administration", "write"),
("checks", "write"),
("contents", "write"),
("members", "read"),
("merge_queues", "read"),
("metadata", "read"),
("organization_administration", "write"),
("pull_requests", "write"),
];
pub const CHECK_PERMISSIONS: [(&str, &str); 3] = [
("checks", "write"),
("merge_queues", "read"),
("pull_requests", "read"),
];
pub const CHECK_EVENTS: [&str; 2] = ["merge_group", "pull_request"];
pub const RESIGN_EVENTS: [&str; 1] = ["push"];
pub const APP_EVENTS: [&str; 11] = [
"branch_protection_rule",
"check_run",
"check_suite",
"member",
"membership",
"merge_group",
"organization",
"pull_request",
"push",
"repository",
"repository_ruleset",
];
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ManifestParams {
pub name: String,
pub url: String,
pub webhook_url: String,
pub redirect_url: String,
pub callback_urls: Vec<String>,
pub setup_url: Option<String>,
pub description: Option<String>,
}
impl ManifestParams {
pub fn new(
name: impl Into<String>,
url: impl Into<String>,
webhook_url: impl Into<String>,
redirect_url: impl Into<String>,
) -> Self {
let redirect_url = redirect_url.into();
ManifestParams {
name: name.into(),
url: url.into(),
webhook_url: webhook_url.into(),
callback_urls: vec![redirect_url.clone()],
redirect_url,
setup_url: None,
description: None,
}
}
pub fn with_setup_url(mut self, url: impl Into<String>) -> Self {
self.setup_url = Some(url.into());
self
}
}
pub fn app_manifest(p: &ManifestParams) -> Value {
let permissions: BTreeMap<_, _> = APP_PERMISSIONS.into_iter().collect();
let mut m = json!({
"name": p.name,
"url": p.url,
"hook_attributes": { "url": p.webhook_url, "active": true },
"redirect_url": p.redirect_url,
"callback_urls": p.callback_urls,
"public": false,
"default_permissions": permissions,
"default_events": APP_EVENTS,
"request_oauth_on_install": false,
});
if let Some(setup) = &p.setup_url {
m["setup_url"] = json!(setup);
m["setup_on_update"] = json!(true);
}
if let Some(d) = &p.description {
m["description"] = json!(d);
}
m
}
pub fn registration_url(web_base: &Url, org: Option<&str>, state: &str) -> Url {
let mut url = web_base.clone();
{
let mut path = url.path_segments_mut().expect("http(s) base");
path.pop_if_empty();
match org {
Some(org) => path.extend(["organizations", org, "settings", "apps", "new"]),
None => path.extend(["settings", "apps", "new"]),
};
}
url.query_pairs_mut().append_pair("state", state);
url
}
pub struct AppCredentials {
pub app_id: u64,
pub slug: String,
pub client_id: String,
pub client_secret: Secret,
pub webhook_secret: Secret,
pub pem: Secret,
pub owner_login: Option<String>,
}
impl fmt::Debug for AppCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AppCredentials")
.field("app_id", &self.app_id)
.field("slug", &self.slug)
.field("client_id", &self.client_id)
.field("client_secret", &"<redacted>")
.field("webhook_secret", &"<redacted>")
.field("pem", &"<redacted>")
.field("owner_login", &self.owner_login)
.finish()
}
}
#[derive(Deserialize)]
struct Conversion {
id: u64,
slug: String,
client_id: String,
client_secret: String,
webhook_secret: Option<String>,
pem: String,
owner: Owner,
permissions: BTreeMap<String, String>,
events: Vec<String>,
#[serde(default)]
public: Option<bool>,
}
pub(crate) fn level_rank(level: &str) -> u8 {
match level {
"read" => 1,
"write" => 2,
_ => 3,
}
}
pub(crate) fn excess_permissions(granted: &BTreeMap<String, String>) -> Vec<String> {
granted
.iter()
.filter(|(name, level)| {
APP_PERMISSIONS
.iter()
.find(|(n, _)| n == name)
.is_none_or(|(_, allowed)| level_rank(level) > level_rank(allowed))
})
.map(|(name, level)| format!("{name}:{level}"))
.collect()
}
pub fn check_ready(granted: &BTreeMap<String, String>, events: &[String]) -> bool {
CHECK_PERMISSIONS.iter().all(|(name, level)| {
granted
.get(*name)
.is_some_and(|have| level_rank(have) >= level_rank(level))
}) && CHECK_EVENTS.iter().all(|e| events.iter().any(|x| x == e))
}
pub(crate) fn missing_permissions(granted: &BTreeMap<String, String>) -> Vec<String> {
APP_PERMISSIONS
.iter()
.filter(|(name, level)| {
granted
.get(*name)
.is_none_or(|have| level_rank(have) < level_rank(level))
})
.map(|(name, level)| format!("{name}:{level}"))
.collect()
}
#[derive(Deserialize)]
struct Owner {
login: String,
}
pub async fn exchange_code(
api_base: &Url,
code: &str,
expected_owner: &str,
) -> Result<AppCredentials> {
if code.is_empty() || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(ForgeError::Config(
"manifest code is not alphanumeric".into(),
));
}
let api = Api::new(
api_base.clone(),
api_base.clone(),
std::time::Duration::from_secs(30),
)?;
let url = api.url(&["app-manifests", code, "conversions"]);
let c: Conversion = api
.json(Method::POST, url, Auth::None, None, "manifest conversion")
.await?;
let creds = AppCredentials {
app_id: c.id,
slug: c.slug,
client_id: c.client_id,
client_secret: Secret::new(c.client_secret),
webhook_secret: Secret::new(c.webhook_secret.unwrap_or_default()),
pem: Secret::new(c.pem),
owner_login: Some(c.owner.login),
};
let refuse = |why: String| {
Err(ForgeError::Config(format!(
"{why}; delete App `{}` on GitHub and register again",
creds.slug
)))
};
let owner = creds.owner_login.as_deref().unwrap_or_default();
if !owner.eq_ignore_ascii_case(expected_owner) {
return refuse(format!(
"the App was registered under `{owner}`, not `{expected_owner}`"
));
}
if c.public == Some(true) {
return refuse("the registered App is public; the manifest asks for a private one".into());
}
let mut events = c.events;
events.sort();
events.dedup();
if events != APP_EVENTS {
return refuse(format!(
"the registered App's events {events:?} differ from the reviewed manifest {APP_EVENTS:?}"
));
}
let excess = excess_permissions(&c.permissions);
if !excess.is_empty() {
return refuse(format!(
"the registered App has permissions beyond the reviewed manifest ({})",
excess.join(", ")
));
}
if creds.webhook_secret.expose().is_empty() {
return Err(ForgeError::Config(
"GitHub returned no webhook secret; webhooks could not be verified".into(),
));
}
Ok(creds)
}