//! `oh` against a real hub: signing in, finding the repository from a //! checkout's remote, issues, Flow runs, releases and their trust report, //! and the raw API. use oxy_core::repo::{IdentityScope, Repo}; use oxygenhub::config::Config; use oxygenhub::repos::RepoName; use oxygenhub::{db, Shared}; use std::path::{Path, PathBuf}; const ROOTFS: &str = "36fc4e3238044e424219a2b9002bf61bbe1805fec14d52cf37e17fae77c9ff16"; fn hermetic() -> PathBuf { static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); let d = DIR.get_or_init(|| tempfile::tempdir().unwrap()); std::env::set_var("OXY_HOME", d.path()); d.path().to_path_buf() } struct Hub { app: Shared, base: String, _dir: tempfile::TempDir, } async fn hub() -> Hub { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.auth.push_tokens = vec!["site".into()]; config.flow.repos = vec!["andy/*".into()]; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); config.server.base_url = format!("http://127.0.0.1:{port}"); let app = oxygenhub::build(config).await.unwrap(); let router = oxygenhub::router(app.clone()); tokio::spawn(async move { axum_serve(listener, router).await }); Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } } async fn axum_serve(listener: tokio::net::TcpListener, router: axum::Router) { axum::serve(listener, router).await.unwrap(); } /// Run `oh` in `dir` with `env`; (exit code, stdout, stderr). async fn oh(dir: &Path, env: &[(&str, &str)], args: &[&str]) -> (i32, String, String) { let (dir, env, args): (PathBuf, Vec<(String, String)>, Vec) = ( dir.to_path_buf(), env.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), args.iter().map(|a| a.to_string()).collect(), ); tokio::task::spawn_blocking(move || { let out = std::process::Command::new(env!("CARGO_BIN_EXE_oh")) .args(&args) .current_dir(&dir) .env_remove("OH_TOKEN") .env_remove("OXY_TOKEN") .env_remove("OH_HUB") .envs(env) .output() .unwrap(); ( out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned(), ) }) .await .unwrap() } fn write(root: &Path, rel: &str, content: &str) { let p = root.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, content).unwrap(); } #[tokio::test(flavor = "multi_thread")] async fn oh_drives_issues_runs_and_releases() { let h = hub().await; // The site token acts as the first site administrator. db::accounts::create_account(&h.app.db, "andy", "a@example.com", "x", "salt", true).await.unwrap(); h.app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap(); let demo = db::repos::create(&h.app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap(); db::issues::upsert_label(&h.app.db, demo.id, "bug", "#d73a4a", "").await.unwrap(); // A checkout whose origin is the hub: `oh` needs nothing else. let work = tempfile::tempdir().unwrap(); let mut c = Repo::init(work.path()).unwrap(); c.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); c.config.set("remote.origin.token", "site").unwrap(); c.save_config().unwrap(); c.create_identity("Andy", "andy@example.com", IdentityScope::Repo).unwrap(); write( work.path(), ".flow/pipeline.kdl", &format!("schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"test\" {{\n sandbox \"alpine\"\n exec \"true\"\n}}\n"), ); c.snapshot("test").unwrap(); c.commit("Add a pipeline", &[]).unwrap(); let heads = c.bookmark_heads().unwrap(); c.tag_create("v1", &heads, "first").unwrap(); assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); let w = work.path(); let (code, out, err) = oh(w, &[], &["repo", "view", "--plain"]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("andy/demo") && out.contains("A demo") && out.contains("oxy clone"), "{out}"); // Issues. let (code, out, err) = oh(w, &[], &["issue", "create", "--title", "Broken thing", "--body", "It breaks."]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("opened #1"), "{out}"); let (_, out, _) = oh(w, &[], &["issue", "list", "--json"]).await; let list: serde_json::Value = serde_json::from_str(&out).unwrap(); assert_eq!((list[0]["number"].as_i64(), list[0]["title"].as_str()), (Some(1), Some("Broken thing"))); let (code, out, err) = oh(w, &[], &["issue", "edit", "1", "--title", "Broken thing, badly", "--label", "bug", "--assignee", "andy"]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("edited #1"), "{out}"); let (_, out, _) = oh(w, &[], &["issue", "view", "1", "--json"]).await; let i: serde_json::Value = serde_json::from_str(&out).unwrap(); assert_eq!(i["title"], "Broken thing, badly"); assert_eq!(i["labels"][0]["name"], "bug"); assert_eq!(i["assignees"][0], "andy"); let (code, _, err) = oh(w, &[], &["issue", "edit", "1"]).await; assert_eq!(code, 2); assert!(err.contains("say what to change"), "{err}"); let (_, out, _) = oh(w, &[], &["issue", "edit", "1", "--no-labels", "--no-assignees", "--json"]).await; let i: serde_json::Value = serde_json::from_str(&out).unwrap(); assert!(i["labels"].as_array().unwrap().is_empty() && i["assignees"].as_array().unwrap().is_empty(), "{out}"); let (_, out, _) = oh(w, &[], &["issue", "comment", "1", "--body", "Seen it too."]).await; assert!(out.contains("commented on #1")); // From a file, as proposal comment takes it (#260); nothing at all is refused. std::fs::write(w.join("note.md"), "Read from a file.\n").unwrap(); let (_, out, _) = oh(w, &[], &["issue", "comment", "1", "-F", "note.md"]).await; assert!(out.contains("commented on #1"), "{out}"); std::fs::remove_file(w.join("note.md")).unwrap(); let (code, _, err) = oh(w, &[], &["issue", "comment", "1"]).await; assert!(code != 0 && err.contains("--body or --body-file"), "{err}"); let (_, out, _) = oh(w, &[], &["issue", "view", "1", "--plain"]).await; assert!(out.contains("It breaks.") && out.contains("Seen it too.") && out.contains("Read from a file."), "{out}"); let (_, out, _) = oh(w, &[], &["issue", "close", "1"]).await; assert!(out.contains("closed #1")); let (_, out, _) = oh(w, &[], &["issue", "list", "--plain"]).await; assert!(out.contains("No open issues."), "{out}"); // Project boards (#115): create, an issue straight onto a column, a // note, view, move, remove. let (code, out, err) = oh(w, &[], &["project", "create", "Roadmap", "-d", "Next up"]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("project 1 Roadmap"), "{out}"); let (code, out, err) = oh(w, &[], &["issue", "create", "--title", "Plan it", "--project", "1", "--column", "doing"]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("opened #2") && out.contains("on project 1"), "{out}"); let (code, _, err) = oh(w, &[], &["project", "card", "add", "1", "--note", "Write docs"]).await; assert_eq!(code, 0, "{err}"); let (code, out, err) = oh(w, &[], &["project", "view", "1", "--plain"]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("Doing") && out.contains("andy/demo#2") && out.contains("Plan it") && out.contains("Write docs"), "{out}"); let (_, out, _) = oh(w, &[], &["project", "view", "1", "--json"]).await; let board: serde_json::Value = serde_json::from_str(&out).unwrap(); let card = board["columns"][1]["cards"][0]["id"].as_i64().unwrap().to_string(); let (code, out, err) = oh(w, &[], &["project", "card", "move", "1", &card, "--column", "Done"]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("moved to Done"), "{out}"); let (_, out, _) = oh(w, &[], &["project", "view", "1", "--json"]).await; let board: serde_json::Value = serde_json::from_str(&out).unwrap(); assert_eq!(board["columns"][2]["cards"][0]["reference"], "andy/demo#2"); let (code, _, err) = oh(w, &[], &["project", "card", "add", "1", "2"]).await; assert_ne!(code, 0); assert!(err.contains("already on this project"), "{err}"); let (code, _, err) = oh(w, &[], &["project", "card", "remove", "1", &card]).await; assert_eq!(code, 0, "{err}"); let (_, out, _) = oh(w, &[], &["project", "list", "--plain"]).await; assert!(out.contains("Roadmap"), "{out}"); let (code, out, _) = oh(w, &[], &["project", "close", "1"]).await; assert_eq!(code, 0); assert!(out.contains("closed project 1"), "{out}"); // Flow: the push started run 1; start another without a push. let (code, out, err) = oh(w, &[], &["run", "start"]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("started run #2"), "{out}"); let (_, out, _) = oh(w, &[], &["run", "list", "--json"]).await; let runs: serde_json::Value = serde_json::from_str(&out).unwrap(); assert_eq!(runs.as_array().unwrap().len(), 2); let (_, out, _) = oh(w, &[], &["run", "view", "2", "--plain"]).await; assert!(out.contains("test") && out.contains("#2"), "{out}"); // Releases, an asset, and the trust report. let (code, out, err) = oh(w, &[], &["release", "create", "v1", "--title", "One", "--notes", "First."]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("released v1"), "{out}"); let asset = w.join("tool.bin"); std::fs::write(&asset, b"bytes").unwrap(); let (code, out, err) = oh(w, &[], &["release", "upload", "v1", asset.to_str().unwrap()]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("attached tool.bin to v1")); std::fs::remove_file(&asset).unwrap(); let (_, out, _) = oh(w, &[], &["release", "list", "--json"]).await; let rel: serde_json::Value = serde_json::from_str(&out).unwrap(); assert_eq!(rel[0]["assets"][0]["name"], "tool.bin"); let (code, out, _) = oh(w, &[], &["release", "trust", "v1", "--plain"]).await; assert_eq!(code, 0, "warnings only: {out}"); assert!(out.contains("Tag v1 is signed") && out.contains("tool.bin: no build found"), "{out}"); // A proposal from a bookmark, landed from the command line. c.bookmark_create("topic", None).unwrap(); c.bookmark_switch("topic", false).unwrap(); write(w, "topic.txt", "more\n"); c.snapshot("test").unwrap(); c.commit("Add topic", &[]).unwrap(); assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); let (code, out, err) = oh(w, &[], &["proposal", "create", "--title", "Topic", "--from", "topic"]).await; assert_eq!(code, 0, "{err}"); let n: String = out.split("proposed #").nth(1).unwrap().chars().take_while(|c| c.is_ascii_digit()).collect(); let (_, out, _) = oh(w, &[], &["proposal", "view", &n, "--plain"]).await; assert!(out.contains("topic → main") && out.contains("Add topic"), "{out}"); let (code, out, err) = oh(w, &[], &["proposal", "land", &n]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains(&format!("landed #{n} on main")), "{out}"); let (code, _, err) = oh(w, &[], &["proposal", "land", &n]).await; assert_ne!(code, 0); assert!(err.contains("HTTP 409") && err.contains("landed"), "{err}"); // Outside a checkout: --repo and a saved login. let elsewhere = tempfile::tempdir().unwrap(); let cfg = elsewhere.path().join("oh.toml"); let cfg_s = cfg.to_str().unwrap(); let (code, _, err) = oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["issue", "list"]).await; assert_ne!(code, 0); assert!(err.contains("--repo"), "{err}"); let (code, out, err) = oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["auth", "login", "--with-hub", &h.base, "--token", "site"]).await; assert_eq!(code, 0, "{err}"); assert!(out.contains("signed in"), "{out}"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!(std::fs::metadata(&cfg).unwrap().permissions().mode() & 0o777, 0o600, "tokens are owner-only"); } let (code, out, _) = oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["issue", "list", "--state", "all", "--repo", "andy/demo", "--plain"]).await; assert_eq!(code, 0); assert!(out.contains("Broken thing"), "{out}"); let (code, out, _) = oh(elsewhere.path(), &[("OH_CONFIG", cfg_s)], &["api", "/repos/andy/demo/labels"]).await; assert_eq!(code, 0); assert!(out.trim_start().starts_with('['), "{out}"); // A bad token says what to do. let (code, _, err) = oh(w, &[("OXY_TOKEN", "nope")], &["issue", "create", "--title", "x"]).await; assert_ne!(code, 0); assert!(err.contains("HTTP 401") && err.contains("oh auth login"), "{err}"); }