github-app-forge 0.1.1

Declarative GitHub App lifecycle management via Manifest flow
Documentation
//! Typed GitHub App Manifest.
//!
//! Mirrors GitHub's App Manifest schema:
//! https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest
//!
//! Authoring surface is YAML — same shape as Slack's manifest, K8s manifests,
//! Akeyless DSL items, etc. Loaded into this typed struct then serialized as
//! the JSON payload GitHub expects.

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;

/// Top-level YAML structure consumed by `github-app-forge create`.
///
/// Combines the GitHub manifest itself with `github-app-forge`-specific knobs:
/// where to install (org or user), where to write credentials, what label/path
/// to use in the chosen sink. Manifest fields are nested under `github` so the
/// outer keys remain stable as the GitHub schema grows.
#[derive(Debug, Clone, Deserialize)]
pub struct ManifestFile {
    /// Friendly name used for filenames + log lines (e.g. `pleme-arc-rio`).
    pub name: String,

    /// `org` for org-installed apps, `user` for personal-account apps.
    pub install_target: InstallTarget,

    /// GitHub org or username the app belongs to.
    pub owner: String,

    /// Optional initial install scope. After app creation, the tool will open
    /// the install URL pre-selecting these repos.
    #[serde(default)]
    pub install_repos: Vec<String>,

    /// Sink config. Default = stdout.
    #[serde(default)]
    pub sink: SinkConfig,

    /// The GitHub manifest body — fields here are sent to GitHub verbatim.
    pub github: GitHubManifest,
}

#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum InstallTarget {
    Org,
    User,
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum SinkConfig {
    /// Print credentials as JSON to stdout. Default.
    #[default]
    Stdout,
    /// Write credentials as plaintext YAML to a file. Testing only.
    File { path: String },
    /// Render a K8s Secret YAML matching pleme-arc-controller's expected shape,
    /// then run `sops --encrypt --in-place` on it. Requires sops + a configured
    /// `.sops.yaml` rule for the target path.
    Sops {
        path: String,
        secret_name: String,
        secret_namespace: String,
    },
    /// Write credentials to Akeyless as a static secret item. (Stub for now;
    /// will use the akeyless CLI under the hood once impl lands.)
    #[allow(dead_code)]
    Akeyless { item_path: String },
}

/// The GitHub manifest body. Field names match GitHub's expected JSON keys
/// exactly (renamed via serde where Rust naming differs).
///
/// **Optional fields use `skip_serializing_if = "Option::is_none"`** — GitHub's
/// manifest validator rejects `null` where it expects "string-or-omitted".
/// Empty values must be omitted from the JSON, not serialized as `null`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubManifest {
    /// Display name of the app (visible in GitHub UI).
    pub name: String,

    /// Homepage URL.
    pub url: String,

    /// One-paragraph description shown to repo admins during install.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// `false` (default) = single-account app; `true` = installable by anyone.
    #[serde(default)]
    pub public: bool,

    /// Webhook config. ARC apps don't use webhooks (long-poll instead) — leave
    /// the default with `active: false` to disable.
    #[serde(default)]
    pub hook_attributes: HookAttributes,

    /// Permissions GitHub will grant to installations.
    /// Map of permission key → access level (`read` | `write` | `admin`).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub default_permissions: BTreeMap<String, Permission>,

    /// Webhook events the app subscribes to. Empty for ARC.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub default_events: Vec<String>,

    /// OAuth callback URLs. Not used by ARC apps.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub callback_urls: Vec<String>,

    /// Setup URL shown after install. Optional.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub setup_url: Option<String>,

    /// If true, app gets re-redirected through setup_url on every update.
    #[serde(default)]
    pub setup_on_update: bool,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HookAttributes {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    /// Default `false` — ARC apps don't use webhooks.
    #[serde(default)]
    pub active: bool,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Permission {
    Read,
    Write,
    Admin,
}

/// Load and validate a manifest YAML file.
pub fn load(path: &Path) -> Result<ManifestFile> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read manifest at {}", path.display()))?;
    let manifest: ManifestFile = serde_yaml_ng::from_str(&content)
        .with_context(|| format!("failed to parse manifest at {}", path.display()))?;
    validate(&manifest)?;
    Ok(manifest)
}

fn validate(m: &ManifestFile) -> Result<()> {
    if m.name.is_empty() {
        return Err(anyhow!("manifest.name is required"));
    }
    if m.owner.is_empty() {
        return Err(anyhow!("manifest.owner is required"));
    }
    if m.github.name.is_empty() {
        return Err(anyhow!("manifest.github.name is required"));
    }
    if m.github.url.is_empty() {
        return Err(anyhow!("manifest.github.url is required"));
    }
    Ok(())
}

impl ManifestFile {
    /// URL for opening the manifest creation UI on GitHub.
    /// `redirect_url` is appended to the manifest body so GitHub redirects
    /// the operator's browser back to our localhost listener after they click
    /// "Create from manifest".
    pub fn manifest_url(&self) -> String {
        match self.install_target {
            InstallTarget::Org => format!(
                "https://github.com/organizations/{}/settings/apps/new",
                self.owner
            ),
            InstallTarget::User => "https://github.com/settings/apps/new".to_string(),
        }
    }

    /// Render the GitHub-facing manifest JSON, embedding the redirect URL.
    pub fn manifest_json(&self, redirect_url: &str) -> Result<String> {
        // GitHub expects redirect_url at the TOP of the manifest, not inside `github:`.
        let mut value = serde_json::to_value(&self.github)?;
        if let serde_json::Value::Object(ref mut obj) = value {
            obj.insert(
                "redirect_url".to_string(),
                serde_json::Value::String(redirect_url.to_string()),
            );
        }
        Ok(serde_json::to_string(&value)?)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    fn write_manifest(content: &str) -> NamedTempFile {
        let mut f = NamedTempFile::new().unwrap();
        f.write_all(content.as_bytes()).unwrap();
        f
    }

    #[test]
    fn rio_manifest_loads() {
        let f = write_manifest(
            r#"
name: pleme-arc-rio
install_target: org
owner: pleme-io
sink:
  kind: stdout
github:
  name: pleme-arc-rio
  url: https://github.com/pleme-io
  default_permissions:
    contents: read
    metadata: read
"#,
        );
        let m = load(f.path()).unwrap();
        assert_eq!(m.name, "pleme-arc-rio");
        assert!(matches!(m.install_target, InstallTarget::Org));
        assert_eq!(m.owner, "pleme-io");
        assert!(matches!(m.sink, SinkConfig::Stdout));
        assert_eq!(m.github.default_permissions.len(), 2);
    }

    #[test]
    fn missing_owner_fails_validation() {
        let f = write_manifest(
            r#"
name: x
install_target: org
owner: ""
sink:
  kind: stdout
github:
  name: x
  url: https://example.com
"#,
        );
        let err = load(f.path()).unwrap_err();
        assert!(err.to_string().contains("manifest.owner is required"));
    }

    #[test]
    fn manifest_json_embeds_redirect_url() {
        let f = write_manifest(
            r#"
name: x
install_target: user
owner: alice
sink:
  kind: stdout
github:
  name: x
  url: https://example.com
"#,
        );
        let m = load(f.path()).unwrap();
        let json = m.manifest_json("http://localhost:1234/callback").unwrap();
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(
            v["redirect_url"].as_str().unwrap(),
            "http://localhost:1234/callback"
        );
        assert_eq!(v["name"].as_str().unwrap(), "x");
    }

    #[test]
    fn manifest_json_omits_unset_optional_fields() {
        // GitHub's manifest validator rejects `null` for fields that should
        // be string-or-omitted. Verify the serializer skips them.
        let f = write_manifest(
            r#"
name: x
install_target: org
owner: alice
sink:
  kind: stdout
github:
  name: x
  url: https://example.com
  default_permissions:
    contents: read
"#,
        );
        let m = load(f.path()).unwrap();
        let json = m.manifest_json("http://localhost:1234/cb").unwrap();
        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
        // Required fields present
        assert_eq!(v["name"], "x");
        assert_eq!(v["url"], "https://example.com");
        // Optional fields with no value: must be ABSENT, not null
        assert!(v.get("description").is_none(), "description should be omitted, got {:?}", v.get("description"));
        assert!(v.get("setup_url").is_none(), "setup_url should be omitted, got {:?}", v.get("setup_url"));
        // Inside hook_attributes
        let ha = &v["hook_attributes"];
        assert!(ha.get("url").is_none(), "hook_attributes.url should be omitted, got {:?}", ha.get("url"));
        // Empty collections also omitted
        assert!(v.get("default_events").is_none() || v["default_events"].as_array().unwrap().is_empty());
        // Required: redirect_url present
        assert_eq!(v["redirect_url"], "http://localhost:1234/cb");
    }

    #[test]
    fn org_manifest_url_is_org_scoped() {
        let f = write_manifest(
            r#"
name: x
install_target: org
owner: pleme-io
sink:
  kind: stdout
github:
  name: x
  url: https://example.com
"#,
        );
        let m = load(f.path()).unwrap();
        assert_eq!(
            m.manifest_url(),
            "https://github.com/organizations/pleme-io/settings/apps/new"
        );
    }
}