use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use super::Listed;
use crate::api::client::{ApiError, GitHub};
use crate::api::model::RepoId;
use crate::api::{has_scope, scope_missing};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PagesForm {
pub workflow: bool,
pub branch: String,
pub path: String,
pub domain: String,
pub https: bool,
}
impl Default for PagesForm {
fn default() -> Self {
Self {
workflow: false,
branch: "main".into(),
path: "/".into(),
domain: String::new(),
https: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Site {
pub url: Option<String>,
pub status: Option<String>,
pub form: PagesForm,
pub certificate: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PagesBuild {
pub status: String,
pub error: Option<String>,
pub pusher: Option<String>,
pub commit: String,
pub duration: u64,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Pages {
pub site: Option<Site>,
pub builds: Result<Listed<PagesBuild>, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PagesChange {
Create { repo: RepoId, form: PagesForm },
Update {
repo: RepoId,
from: Box<PagesForm>,
to: Box<PagesForm>,
},
Delete { repo: RepoId },
}
const BUILDS: usize = 10;
fn update_body(from: Option<&PagesForm>, to: &PagesForm) -> Value {
let mut body = Map::new();
let changed = |f: fn(&PagesForm) -> Value| from.is_none_or(|from| f(from) != f(to));
let source = |f: &PagesForm| json!({ "branch": f.branch, "path": f.path });
if changed(|f| f.workflow.into()) {
let kind = if to.workflow { "workflow" } else { "legacy" };
body.insert("build_type".into(), kind.into());
}
if !to.workflow && (changed(source) || changed(|f| f.workflow.into())) {
body.insert("source".into(), source(to));
}
if changed(|f| f.domain.clone().into()) {
let domain = Some(to.domain.as_str()).filter(|d| !d.is_empty());
body.insert("cname".into(), domain.into());
}
if changed(|f| f.https.into()) {
body.insert("https_enforced".into(), to.https.into());
}
Value::Object(body)
}
mod wire {
use serde::Deserialize;
#[derive(Deserialize)]
pub(super) struct Site {
pub html_url: Option<String>,
pub status: Option<String>,
pub cname: Option<String>,
#[serde(default)]
pub https_enforced: bool,
pub build_type: Option<String>,
pub source: Option<Source>,
pub https_certificate: Option<Certificate>,
}
#[derive(Deserialize)]
pub(super) struct Source {
pub branch: String,
#[serde(default)]
pub path: String,
}
#[derive(Deserialize)]
pub(super) struct Certificate {
pub state: String,
}
#[derive(Deserialize)]
pub(super) struct Build {
#[serde(default)]
pub status: String,
pub error: Option<Error>,
pub pusher: Option<crate::api::browse::UserLogin>,
#[serde(default)]
pub commit: String,
#[serde(default)]
pub duration: u64,
#[serde(default)]
pub created_at: String,
}
#[derive(Deserialize)]
pub(super) struct Error {
pub message: Option<String>,
}
}
impl GitHub {
pub(super) async fn pages(&self, repo: &RepoId) -> Result<Pages, ApiError> {
let path = format!("/repos/{repo}/pages");
let answer = self.rest_refusable(&path, "its Pages site", |status, _, answer| {
let scopes = answer.header("x-oauth-scopes");
match status {
404 if scopes.is_some_and(|s| !has_scope(s, "repo")) => {
Some(Err(scope_missing("Its Pages site", "repo")))
}
404 => Some(Ok(())),
403 => Some(Err("GitHub doesn't show you its Pages site".to_owned())),
_ => None,
}
});
let site = match answer.await? {
Ok(answer) => serde_json::from_str::<wire::Site>(answer.body())?,
Err(Ok(())) => {
return Ok(Pages {
site: None,
builds: Ok(Listed {
items: Vec::new(),
more: false,
}),
});
}
Err(Err(why)) => return Err(ApiError::Denied(why)),
};
let builds = format!("/repos/{repo}/pages/builds?per_page={BUILDS}");
let builds = self.rest_refusable(&builds, "its Pages builds", |status, _, _| {
matches!(status, 403 | 404).then(|| "GitHub doesn't show you its builds".to_owned())
});
let builds = match builds.await? {
Err(why) => Err(why),
Ok(answer) => {
let found: Vec<wire::Build> = serde_json::from_str(answer.body())?;
let items = (found.into_iter())
.map(|b| PagesBuild {
status: b.status,
error: b.error.and_then(|e| e.message).filter(|m| !m.is_empty()),
pusher: b.pusher.map(|p| p.login),
commit: b.commit,
duration: b.duration,
created_at: b.created_at,
})
.collect();
let more = answer.next_page().is_some();
Ok(Listed { items, more })
}
};
let workflow = site.build_type.as_deref() == Some("workflow");
let source = site.source;
Ok(Pages {
site: Some(Site {
url: site.html_url,
status: site.status,
form: PagesForm {
workflow,
branch: source
.as_ref()
.map_or_else(String::new, |s| s.branch.clone()),
path: source.map_or_else(|| "/".into(), |s| s.path),
domain: site.cname.unwrap_or_default(),
https: site.https_enforced,
},
certificate: site.https_certificate.map(|c| c.state),
}),
builds,
})
}
pub(super) async fn change_pages(&self, change: &PagesChange) -> Result<(), ApiError> {
use http::Method;
match change {
PagesChange::Create { repo, form } => {
let path = format!("/repos/{repo}/pages");
let kind = if form.workflow { "workflow" } else { "legacy" };
let mut body = json!({ "build_type": kind });
if !form.workflow
&& let Value::Object(b) = &mut body
{
b.insert(
"source".into(),
json!({"branch": form.branch, "path": form.path}),
);
}
self.rest_write(Method::POST, &path, Some(&body)).await?;
if form.domain.is_empty() {
return Ok(());
}
let body = json!({ "cname": form.domain });
self.rest_write(Method::PUT, &path, Some(&body)).await
}
PagesChange::Update { repo, from, to } => {
let path = format!("/repos/{repo}/pages");
let body = update_body(Some(from), to);
self.rest_write(Method::PUT, &path, Some(&body)).await
}
PagesChange::Delete { repo } => {
let path = format!("/repos/{repo}/pages");
self.rest_write(Method::DELETE, &path, None).await
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pages_change_sends_what_changed() {
let from = PagesForm {
domain: "docs.example.com".into(),
..PagesForm::default()
};
let to = PagesForm {
path: "/docs".into(),
domain: String::new(),
..from.clone()
};
assert_eq!(
update_body(Some(&from), &to),
json!({"source": {"branch": "main", "path": "/docs"}, "cname": null})
);
let workflow = PagesForm {
workflow: true,
..from.clone()
};
assert_eq!(
update_body(Some(&workflow), &from),
json!({"build_type": "legacy", "source": {"branch": "main", "path": "/"}})
);
}
}