Skip to main content

github_app_forge/
manifest.rs

1//! Typed GitHub App Manifest.
2//!
3//! Mirrors GitHub's App Manifest schema:
4//! https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest
5//!
6//! Authoring surface is YAML — same shape as Slack's manifest, K8s manifests,
7//! Akeyless DSL items, etc. Loaded into this typed struct then serialized as
8//! the JSON payload GitHub expects.
9
10use anyhow::{anyhow, Context, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::Path;
14
15/// Top-level YAML structure consumed by `github-app-forge create`.
16///
17/// Combines the GitHub manifest itself with `github-app-forge`-specific knobs:
18/// where to install (org or user), where to write credentials, what label/path
19/// to use in the chosen sink. Manifest fields are nested under `github` so the
20/// outer keys remain stable as the GitHub schema grows.
21#[derive(Debug, Clone, Deserialize)]
22pub struct ManifestFile {
23    /// Friendly name used for filenames + log lines (e.g. `pleme-arc-rio`).
24    pub name: String,
25
26    /// `org` for org-installed apps, `user` for personal-account apps.
27    pub install_target: InstallTarget,
28
29    /// GitHub org or username the app belongs to.
30    pub owner: String,
31
32    /// Optional initial install scope. After app creation, the tool will open
33    /// the install URL pre-selecting these repos.
34    #[serde(default)]
35    pub install_repos: Vec<String>,
36
37    /// Sink config. Default = stdout.
38    #[serde(default)]
39    pub sink: SinkConfig,
40
41    /// The GitHub manifest body — fields here are sent to GitHub verbatim.
42    pub github: GitHubManifest,
43}
44
45#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
46#[serde(rename_all = "lowercase")]
47pub enum InstallTarget {
48    Org,
49    User,
50}
51
52#[derive(Debug, Clone, Default, Deserialize)]
53#[serde(rename_all = "snake_case", tag = "kind")]
54pub enum SinkConfig {
55    /// Print credentials as JSON to stdout. Default.
56    #[default]
57    Stdout,
58    /// Write credentials as plaintext YAML to a file. Testing only.
59    File { path: String },
60    /// Render a K8s Secret YAML matching pleme-arc-controller's expected shape,
61    /// then run `sops --encrypt --in-place` on it. Requires sops + a configured
62    /// `.sops.yaml` rule for the target path.
63    Sops {
64        path: String,
65        secret_name: String,
66        secret_namespace: String,
67    },
68    /// Write credentials to Akeyless as a static secret item. (Stub for now;
69    /// will use the akeyless CLI under the hood once impl lands.)
70    #[allow(dead_code)]
71    Akeyless { item_path: String },
72}
73
74/// The GitHub manifest body. Field names match GitHub's expected JSON keys
75/// exactly (renamed via serde where Rust naming differs).
76///
77/// **Optional fields use `skip_serializing_if = "Option::is_none"`** — GitHub's
78/// manifest validator rejects `null` where it expects "string-or-omitted".
79/// Empty values must be omitted from the JSON, not serialized as `null`.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct GitHubManifest {
82    /// Display name of the app (visible in GitHub UI).
83    pub name: String,
84
85    /// Homepage URL.
86    pub url: String,
87
88    /// One-paragraph description shown to repo admins during install.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub description: Option<String>,
91
92    /// `false` (default) = single-account app; `true` = installable by anyone.
93    #[serde(default)]
94    pub public: bool,
95
96    /// Webhook config. ARC apps don't use webhooks (long-poll instead) — leave
97    /// the default with `active: false` to disable.
98    #[serde(default)]
99    pub hook_attributes: HookAttributes,
100
101    /// Permissions GitHub will grant to installations.
102    /// Map of permission key → access level (`read` | `write` | `admin`).
103    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
104    pub default_permissions: BTreeMap<String, Permission>,
105
106    /// Webhook events the app subscribes to. Empty for ARC.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub default_events: Vec<String>,
109
110    /// OAuth callback URLs. Not used by ARC apps.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub callback_urls: Vec<String>,
113
114    /// Setup URL shown after install. Optional.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub setup_url: Option<String>,
117
118    /// If true, app gets re-redirected through setup_url on every update.
119    #[serde(default)]
120    pub setup_on_update: bool,
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct HookAttributes {
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub url: Option<String>,
127    /// Default `false` — ARC apps don't use webhooks.
128    #[serde(default)]
129    pub active: bool,
130}
131
132#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
133#[serde(rename_all = "lowercase")]
134pub enum Permission {
135    Read,
136    Write,
137    Admin,
138}
139
140/// Load and validate a manifest YAML file.
141pub fn load(path: &Path) -> Result<ManifestFile> {
142    let content = std::fs::read_to_string(path)
143        .with_context(|| format!("failed to read manifest at {}", path.display()))?;
144    let manifest: ManifestFile = serde_yaml_ng::from_str(&content)
145        .with_context(|| format!("failed to parse manifest at {}", path.display()))?;
146    validate(&manifest)?;
147    Ok(manifest)
148}
149
150fn validate(m: &ManifestFile) -> Result<()> {
151    if m.name.is_empty() {
152        return Err(anyhow!("manifest.name is required"));
153    }
154    if m.owner.is_empty() {
155        return Err(anyhow!("manifest.owner is required"));
156    }
157    if m.github.name.is_empty() {
158        return Err(anyhow!("manifest.github.name is required"));
159    }
160    if m.github.url.is_empty() {
161        return Err(anyhow!("manifest.github.url is required"));
162    }
163    Ok(())
164}
165
166impl ManifestFile {
167    /// URL for opening the manifest creation UI on GitHub.
168    /// `redirect_url` is appended to the manifest body so GitHub redirects
169    /// the operator's browser back to our localhost listener after they click
170    /// "Create from manifest".
171    pub fn manifest_url(&self) -> String {
172        match self.install_target {
173            InstallTarget::Org => format!(
174                "https://github.com/organizations/{}/settings/apps/new",
175                self.owner
176            ),
177            InstallTarget::User => "https://github.com/settings/apps/new".to_string(),
178        }
179    }
180
181    /// Render the GitHub-facing manifest JSON, embedding the redirect URL.
182    pub fn manifest_json(&self, redirect_url: &str) -> Result<String> {
183        // GitHub expects redirect_url at the TOP of the manifest, not inside `github:`.
184        let mut value = serde_json::to_value(&self.github)?;
185        if let serde_json::Value::Object(ref mut obj) = value {
186            obj.insert(
187                "redirect_url".to_string(),
188                serde_json::Value::String(redirect_url.to_string()),
189            );
190        }
191        Ok(serde_json::to_string(&value)?)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::io::Write;
199    use tempfile::NamedTempFile;
200
201    fn write_manifest(content: &str) -> NamedTempFile {
202        let mut f = NamedTempFile::new().unwrap();
203        f.write_all(content.as_bytes()).unwrap();
204        f
205    }
206
207    #[test]
208    fn rio_manifest_loads() {
209        let f = write_manifest(
210            r#"
211name: pleme-arc-rio
212install_target: org
213owner: pleme-io
214sink:
215  kind: stdout
216github:
217  name: pleme-arc-rio
218  url: https://github.com/pleme-io
219  default_permissions:
220    contents: read
221    metadata: read
222"#,
223        );
224        let m = load(f.path()).unwrap();
225        assert_eq!(m.name, "pleme-arc-rio");
226        assert!(matches!(m.install_target, InstallTarget::Org));
227        assert_eq!(m.owner, "pleme-io");
228        assert!(matches!(m.sink, SinkConfig::Stdout));
229        assert_eq!(m.github.default_permissions.len(), 2);
230    }
231
232    #[test]
233    fn missing_owner_fails_validation() {
234        let f = write_manifest(
235            r#"
236name: x
237install_target: org
238owner: ""
239sink:
240  kind: stdout
241github:
242  name: x
243  url: https://example.com
244"#,
245        );
246        let err = load(f.path()).unwrap_err();
247        assert!(err.to_string().contains("manifest.owner is required"));
248    }
249
250    #[test]
251    fn manifest_json_embeds_redirect_url() {
252        let f = write_manifest(
253            r#"
254name: x
255install_target: user
256owner: alice
257sink:
258  kind: stdout
259github:
260  name: x
261  url: https://example.com
262"#,
263        );
264        let m = load(f.path()).unwrap();
265        let json = m.manifest_json("http://localhost:1234/callback").unwrap();
266        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
267        assert_eq!(
268            v["redirect_url"].as_str().unwrap(),
269            "http://localhost:1234/callback"
270        );
271        assert_eq!(v["name"].as_str().unwrap(), "x");
272    }
273
274    #[test]
275    fn manifest_json_omits_unset_optional_fields() {
276        // GitHub's manifest validator rejects `null` for fields that should
277        // be string-or-omitted. Verify the serializer skips them.
278        let f = write_manifest(
279            r#"
280name: x
281install_target: org
282owner: alice
283sink:
284  kind: stdout
285github:
286  name: x
287  url: https://example.com
288  default_permissions:
289    contents: read
290"#,
291        );
292        let m = load(f.path()).unwrap();
293        let json = m.manifest_json("http://localhost:1234/cb").unwrap();
294        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
295        // Required fields present
296        assert_eq!(v["name"], "x");
297        assert_eq!(v["url"], "https://example.com");
298        // Optional fields with no value: must be ABSENT, not null
299        assert!(v.get("description").is_none(), "description should be omitted, got {:?}", v.get("description"));
300        assert!(v.get("setup_url").is_none(), "setup_url should be omitted, got {:?}", v.get("setup_url"));
301        // Inside hook_attributes
302        let ha = &v["hook_attributes"];
303        assert!(ha.get("url").is_none(), "hook_attributes.url should be omitted, got {:?}", ha.get("url"));
304        // Empty collections also omitted
305        assert!(v.get("default_events").is_none() || v["default_events"].as_array().unwrap().is_empty());
306        // Required: redirect_url present
307        assert_eq!(v["redirect_url"], "http://localhost:1234/cb");
308    }
309
310    #[test]
311    fn org_manifest_url_is_org_scoped() {
312        let f = write_manifest(
313            r#"
314name: x
315install_target: org
316owner: pleme-io
317sink:
318  kind: stdout
319github:
320  name: x
321  url: https://example.com
322"#,
323        );
324        let m = load(f.path()).unwrap();
325        assert_eq!(
326            m.manifest_url(),
327            "https://github.com/organizations/pleme-io/settings/apps/new"
328        );
329    }
330}