OXYGENHUB

Proposal clients: oxy seal --proposal, oxy proposal show|land, seal-only pushes, http_get for hub APIs d82e44097323

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 18:44 UTC
Signature
✔ signed by Andy McCall
Hash
d82e4409732354d11b91b19428c889fb93f0cfbc234eb0650da490aace6d2c2e
Depends on
05e71ed6cdf1 135212de5370 50a046e439e9 5b9c6ad45f6e 750251b21a96 982864068a59 c6aa5bd9d1c8 fce44ae9ad29 ff0af1ee299d
Depended on by
91334b7fa574 c8d9059e4e70 ee130dd9eef2 b582948f1912 2c9ddac3c387 ff1f62fee654 fc1a466a1e66 62794ecc7f74 d42369abf180 3e5772ccf9da 4213c41dfc64
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
15 atoms, 1 tree operation, 6 files

Browse the tree at this change

added src/cli/proposal.rs +152 −0
1 +
//! Thin clients of a hub's proposals (hub spec §7): `oxy proposal show`,
2 +
//! `oxy proposal land`, and `oxy seal --proposal`. The hub's JSON is read
3 +
//! by field name (hub-api `Proposal`); nothing here needs the hub crate.
4 +
5 +
use super::{open, ui};
6 +
use anyhow::{anyhow, Context, Result};
7 +
use oxy_core::hash::ChangeHash;
8 +
use oxy_core::repo::Repo;
9 +
use serde_json::Value;
10 +
11 +
/// `https://hub/{owner}/{name}/proposal/{n}` (or the API URL) to the API
12 +
/// URL and the repository's clone URL.
13 +
pub fn api_url(url: &str) -> Result<(String, String)> {
14 +
    let url = url.trim_end_matches('/');
15 +
    if url.contains("/api/v1/repos/") {
16 +
        let (base, rest) = url.split_once("/api/v1/repos/").unwrap();
17 +
        let mut parts = rest.split('/');
18 +
        let (owner, name) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
19 +
        return Ok((url.to_string(), format!("{base}/{owner}/{name}")));
20 +
    }
21 +
    let (repo_url, number) = url.rsplit_once("/proposal/").ok_or_else(|| anyhow!("{url} is not a proposal URL (…/OWNER/NAME/proposal/N)"))?;
22 +
    let (base, slug) = repo_url.rsplit_once('/').and_then(|(b, n)| b.rsplit_once('/').map(|(bb, o)| (bb, format!("{o}/{n}")))).ok_or_else(|| anyhow!("{url} is not a proposal URL"))?;
23 +
    Ok((format!("{base}/api/v1/repos/{slug}/proposals/{number}"), repo_url.to_string()))
24 +
}
25 +
26 +
pub fn fetch(repo: &Repo, url: &str) -> Result<(Value, String)> {
27 +
    let (api, repo_url) = api_url(url)?;
28 +
    let options = repo.options_for_url(&repo_url);
29 +
    let body = oxy_core::protocol::http_get(&api, &options).map_err(|e| anyhow!("{e}"))?;
30 +
    let v: Value = serde_json::from_slice(&body).context("the hub did not answer with JSON")?;
31 +
    Ok((v, repo_url))
32 +
}
33 +
34 +
fn hashes(v: &Value, key: &str) -> Vec<ChangeHash> {
35 +
    v.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().and_then(ChangeHash::from_hex)).collect()).unwrap_or_default()
36 +
}
37 +
38 +
fn change_hashes(v: &Value) -> Vec<ChangeHash> {
39 +
    v.get("changes").and_then(Value::as_array).map(|a| a.iter().filter_map(|c| c.get("hash").and_then(Value::as_str).and_then(ChangeHash::from_hex)).collect()).unwrap_or_default()
40 +
}
41 +
42 +
fn text<'a>(v: &'a Value, key: &str) -> &'a str {
43 +
    v.get(key).and_then(Value::as_str).unwrap_or("")
44 +
}
45 +
46 +
pub fn show(url: &str, as_json: bool) -> Result<i32> {
47 +
    let repo = open()?;
48 +
    let (v, _) = fetch(&repo, url)?;
49 +
    if as_json {
50 +
        println!("{v}");
51 +
        return Ok(0);
52 +
    }
53 +
    println!("{} #{} {}", ui::done("Proposal"), v.get("number").and_then(Value::as_i64).unwrap_or(0), ui::bookmark(text(&v, "title")));
54 +
    println!("{}", ui::kv("state", text(&v, "state")));
55 +
    println!("{}", ui::kv("target", ui::bookmark(text(&v, "target"))));
56 +
    println!("{}", ui::kv("author", ui::author(text(&v, "author"))));
57 +
    let changes = v.get("changes").and_then(Value::as_array).cloned().unwrap_or_default();
58 +
    println!("{}", ui::kv("changes", changes.len().to_string()));
59 +
    for c in &changes {
60 +
        println!("  {} {}", ui::hash(&text(c, "hash")[..12.min(text(c, "hash").len())]), text(c, "message").lines().next().unwrap_or(""));
61 +
    }
62 +
    if v.get("conflicted").and_then(Value::as_bool).unwrap_or(false) {
63 +
        println!("{}", ui::warn("landing would carry conflicts"));
64 +
    }
65 +
    if let Some(blockers) = v.get("blockers").and_then(Value::as_array) {
66 +
        for b in blockers {
67 +
            println!("  {} {}", ui::warn("blocked:"), b.as_str().unwrap_or(""));
68 +
        }
69 +
    }
70 +
    let approvals = v.get("approvals").and_then(Value::as_array).cloned().unwrap_or_default();
71 +
    for a in &approvals {
72 +
        let mark = if a.get("sealed").and_then(Value::as_bool).unwrap_or(false) { ui::done("approved") } else { ui::warn("pending seal") };
73 +
        println!("  {mark} {}", text(a, "account"));
74 +
    }
75 +
    Ok(0)
76 +
}
77 +
78 +
/// Seal every change of the proposal with your key and push the seal to
79 +
/// the hub, so your approval counts (hub spec §7.4).
80 +
pub fn seal(url: &str, as_json: bool) -> Result<i32> {
81 +
    let repo = open()?;
82 +
    let (v, repo_url) = fetch(&repo, url)?;
83 +
    let hashes = change_hashes(&v);
84 +
    if hashes.is_empty() {
85 +
        anyhow::bail!("the proposal has no changes to seal");
86 +
    }
87 +
    ensure_changes(&repo, &repo_url, &hashes, &hashes)?;
88 +
    let mut refs = Vec::new();
89 +
    for chunk in hashes.chunks(oxy_core::attest::MAX_SEAL_HASHES) {
90 +
        refs.push(repo.seal(chunk)?);
91 +
    }
92 +
    let remote = repo.remote_for_url(&repo_url).ok_or_else(|| anyhow!("no remote is configured for {repo_url}; add one with `oxy remote add`"))?;
93 +
    repo.push_seals(&remote, &refs)?;
94 +
    if as_json {
95 +
        let list: Vec<String> = hashes.iter().map(|h| format!("\"{}\"", h.to_hex())).collect();
96 +
        println!("{{\"sealed\":[{}],\"pushed\":true}}", list.join(","));
97 +
    } else {
98 +
        println!("{} {} change{} of the proposal and pushed the seal to {remote}", ui::done("Sealed"), hashes.len(), if hashes.len() == 1 { "" } else { "s" });
99 +
    }
100 +
    Ok(0)
101 +
}
102 +
103 +
/// Fetch the proposal's changes (and anything they need) from the hub
104 +
/// into the local store.
105 +
fn ensure_changes(repo: &Repo, repo_url: &str, want: &[ChangeHash], _changes: &[ChangeHash]) -> Result<()> {
106 +
    let missing: Vec<ChangeHash> = want.iter().copied().filter(|h| !repo.store().has(h)).collect();
107 +
    if missing.is_empty() {
108 +
        return Ok(());
109 +
    }
110 +
    let (_, remote) = repo.open_remote_at(repo_url)?;
111 +
    let view = repo.view()?;
112 +
    let have = repo.known_heads(&view)?;
113 +
    let needed = remote.negotiate(&have, want)?;
114 +
    let bundle = remote.fetch(&needed, repo.config.max_blob)?;
115 +
    repo.import_bundle(&bundle, false)?;
116 +
    Ok(())
117 +
}
118 +
119 +
/// Land the proposal from this client: fetch its changes, set the target
120 +
/// bookmark to the projected heads, and push with your own key (hub spec
121 +
/// §7.6).
122 +
pub fn land(url: &str, force: bool) -> Result<i32> {
123 +
    let repo = open()?;
124 +
    let (v, repo_url) = fetch(&repo, url)?;
125 +
    let state = text(&v, "state");
126 +
    if state != "open" && !force {
127 +
        anyhow::bail!("the proposal is {state}, not open (use --force to land anyway)");
128 +
    }
129 +
    if !v.get("landable").and_then(Value::as_bool).unwrap_or(false) && !force {
130 +
        let blockers: Vec<String> = v.get("blockers").and_then(Value::as_array).map(|a| a.iter().filter_map(|b| b.as_str().map(String::from)).collect()).unwrap_or_default();
131 +
        anyhow::bail!("the hub says the proposal cannot land yet: {} (use --force to land anyway)", blockers.join("; "));
132 +
    }
133 +
    let target = text(&v, "target").to_string();
134 +
    let heads = hashes(&v, "projected_heads");
135 +
    if heads.is_empty() {
136 +
        anyhow::bail!("the hub has not computed the projected state yet; try again shortly");
137 +
    }
138 +
    ensure_changes(&repo, &repo_url, &heads, &change_hashes(&v))?;
139 +
    let remote = repo.remote_for_url(&repo_url).ok_or_else(|| anyhow!("no remote is configured for {repo_url}; add one with `oxy remote add`"))?;
140 +
    // Bring the target bookmark up to date first, so the push is a fast-forward.
141 +
    repo.fetch(&remote)?;
142 +
    repo.bookmark_set(&target, &heads)?;
143 +
    let results = repo.push_bookmark(&remote, &target, false)?;
144 +
    for r in &results {
145 +
        if r.ok {
146 +
            println!("{} {}/{} - landed proposal", ui::done("Pushed"), remote, r.name);
147 +
        } else {
148 +
            println!("{} {}/{} - {}", ui::warn("Rejected"), remote, r.name, r.reason);
149 +
        }
150 +
    }
151 +
    Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 })
152 +
}
modified Cargo.toml +1 −1
…
26 unchanged lines
27 27
libc = "0.2"
28 28
console = "0.15"
29 29
indicatif = "0.17"
30 +
serde_json = "1"
30 31
31 32
[dev-dependencies]
32 33
tempfile = "3"
33 -
serde_json = "1"
modified crates/oxy-core/src/protocol.rs +26 −0
…
298 unchanged lines
299 299
    }
300 300
}
301 301
302 +
/// An authenticated GET of any URL on a host, with the same TLS trust and
303 +
/// token as remotes use (for hub APIs: proposals). Returns the body.
304 +
pub fn http_get(url: &str, options: &RemoteOptions) -> Result<Vec<u8>> {
305 +
    let agent = ureq::AgentBuilder::new()
306 +
        .tls_config(tls_client_config(options.ca.as_deref())?)
307 +
        .build();
308 +
    let mut req = agent.get(url).set("Accept", "application/json");
309 +
    if let Some(t) = &options.token {
310 +
        req = req.set("Authorization", &format!("Bearer {t}"));
311 +
    }
312 +
    match req.call() {
313 +
        Ok(r) => {
314 +
            let mut out = Vec::new();
315 +
            r.into_reader()
316 +
                .read_to_end(&mut out)
317 +
                .map_err(|e| RepoError::Msg(format!("{url}: {e}")))?;
318 +
            Ok(out)
319 +
        }
320 +
        Err(ureq::Error::Status(code, r)) => {
321 +
            let text = r.into_string().unwrap_or_default();
322 +
            Err(RepoError::Msg(format!("{url}: HTTP {code}: {}", text.trim())))
323 +
        }
324 +
        Err(e) => Err(RepoError::Msg(format!("{url}: {e}"))),
325 +
    }
326 +
}
327 +
302 328
impl Remote for HttpRemote {
303 329
    fn repo_id(&self) -> Result<[u8; 16]> {
304 330
        wire::decode_capabilities(&self.call(false, "capabilities", None)?)
…
670 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +52 −1
…
682 unchanged lines
683 683
        }
684 684
    }
685 685
686 +
    /// Push seal attestations on their own (hub spec §7.4): a bundle with
687 +
    /// no changes and no reference updates, which the receiver stores.
688 +
    pub fn push_seals(&self, remote_name: &str, seals: &[ObjectRef]) -> Result<()> {
689 +
        let remote = self.open_named_remote(remote_name)?;
690 +
        self.check_remote_id(remote.as_ref(), remote_name, false)?;
691 +
        let mut bundle = Bundle::default();
692 +
        for r in seals {
693 +
            let attest = self.store.get_attest(r)?;
694 +
            let sigs = self.store.signatures(r)?;
695 +
            bundle.attests.push(crate::protocol::SignedAttest { attest, sigs });
696 +
        }
697 +
        let results = remote.push(bundle, vec![], None, vec![])?;
698 +
        if let Some(r) = results.iter().find(|r| !r.ok) {
699 +
            return fail(format!("{}: {}", r.name, r.reason));
700 +
        }
701 +
        Ok(())
702 +
    }
703 +
704 +
    /// The remote whose URL starts with `base` (a hub), if configured.
705 +
    pub fn remote_for_url(&self, base: &str) -> Option<String> {
706 +
        let base = base.trim_end_matches('/');
707 +
        self.config.remotes.iter().find(|(_, url)| url.trim_end_matches('/') == base).map(|(name, _)| name.clone())
708 +
    }
709 +
710 +
    /// Open a remote by name or, failing that, by URL with the default
711 +
    /// credentials (`OXY_TOKEN`, `OXY_CA`).
712 +
    pub fn open_remote_at(&self, url: &str) -> Result<(String, Box<dyn crate::protocol::Remote>)> {
713 +
        if let Some(name) = self.remote_for_url(url) {
714 +
            return Ok((name.clone(), self.open_named_remote(&name)?));
715 +
        }
716 +
        let options = RemoteOptions {
717 +
            token: std::env::var("OXY_TOKEN").ok(),
718 +
            ca: std::env::var("OXY_CA").ok().map(PathBuf::from),
719 +
            repo_id: self.id().ok(),
720 +
        };
721 +
        Ok((url.to_string(), open_remote(url, &options)?))
722 +
    }
723 +
724 +
    /// Credentials for a URL: those of the configured remote with that
725 +
    /// URL, else the environment's.
726 +
    pub fn options_for_url(&self, url: &str) -> RemoteOptions {
727 +
        match self.remote_for_url(url) {
728 +
            Some(name) => self.remote_options(&name),
729 +
            None => RemoteOptions {
730 +
                token: std::env::var("OXY_TOKEN").ok(),
731 +
                ca: std::env::var("OXY_CA").ok().map(PathBuf::from),
732 +
                repo_id: self.id().ok(),
733 +
            },
734 +
        }
735 +
    }
736 +
686 737
    fn open_named_remote(&self, name: &str) -> Result<Box<dyn crate::protocol::Remote>> {
687 738
        open_remote(&self.remote_url(name)?, &self.remote_options(name))
688 739
    }
…
94 unchanged lines
783 834
    }
784 835
785 836
    /// Every head this repository knows: bookmarks, remote tracking, working copy.
786 -
    fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> {
837 +
    pub fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> {
787 838
        let mut have: BTreeSet<ChangeHash> = BTreeSet::new();
788 839
        for st in view
789 840
            .bookmarks
…
590 unchanged lines
modified docs/spec.md +2 −1
…
830 unchanged lines
831 831
oxy remote add|remove|list          oxy serve                    oxy lock|unlock PATH
832 832
oxy identity create|show|export|rotate|renew|revoke|delegate
833 833
oxy policy init|show|grant|revoke|root|sign                      oxy verify [CHANGE]
834 -
oxy seal [CHANGE]..                 seal your unauthenticated and anonymous changes (§14.4)
834 +
oxy seal [CHANGE].. | --proposal URL  seal your unauthenticated and anonymous changes (§14.4), or a hub proposal's changes as your approval
835 +
oxy proposal show|land URL          hub proposals from a client (hub spec §7)
835 836
oxy git import|export|fetch|push   (fetch and push go through a bare mirror under .oxy/git/)
836 837
oxy checkpoint                      oxy gc [--now]               oxy config [--user] KEY VALUE
837 838
oxy root                            oxy cat PATH [-r CHANGE]     oxy version
…
144 unchanged lines
modified src/cli/mod.rs +32 −1
…
2 unchanged lines
3 3
mod format;
4 4
mod identity;
5 5
mod json;
6 +
mod proposal;
6 7
mod sync;
7 8
pub mod ui;
8 9
mod workspace;
…
289 unchanged lines
298 299
    /// Seal changes with your key: named ones, or every change on the current bookmark you can authenticate but have not
299 300
    Seal {
300 301
        changes: Vec<String>,
302 +
        /// Seal every change of a hub proposal and push the seal (your approval)
303 +
        #[arg(long, conflicts_with = "changes")]
304 +
        proposal: Option<String>,
301 305
        #[arg(long)]
302 306
        json: bool,
303 307
    },
308 +
    /// Hub proposals: show one, or land it from this client
309 +
    Proposal {
310 +
        #[command(subcommand)]
311 +
        cmd: ProposalCmd,
312 +
    },
304 313
    /// Verify the signatures on a change (default: every change in the log)
305 314
    Verify {
306 315
        change: Option<String>,
…
143 unchanged lines
450 459
}
451 460
452 461
#[derive(Subcommand)]
462 +
enum ProposalCmd {
463 +
    /// Show a proposal by its hub URL
464 +
    Show {
465 +
        url: String,
466 +
        #[arg(long)]
467 +
        json: bool,
468 +
    },
469 +
    /// Fetch the proposal's changes, set the target bookmark to the projected state, and push with your key
470 +
    Land {
471 +
        url: String,
472 +
        /// Land even when the hub reports blockers
473 +
        #[arg(long)]
474 +
        force: bool,
475 +
    },
476 +
}
477 +
478 +
#[derive(Subcommand)]
453 479
enum IdentityCmd {
454 480
    /// Generate a composite Ed25519 + ML-DSA-65 key pair and make it your author identity
455 481
    Create {
…
311 unchanged lines
767 793
            },
768 794
            PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out),
769 795
        },
770 -
        Cmd::Seal { changes, json } => identity::seal(&changes, json),
796 +
        Cmd::Seal { changes, proposal: Some(url), json } if changes.is_empty() => proposal::seal(&url, json),
797 +
        Cmd::Seal { changes, json, .. } => identity::seal(&changes, json),
798 +
        Cmd::Proposal { cmd } => match cmd {
799 +
            ProposalCmd::Show { url, json } => proposal::show(&url, json),
800 +
            ProposalCmd::Land { url, force } => proposal::land(&url, force),
801 +
        },
771 802
        Cmd::Verify { change, json } => identity::verify(change.as_deref(), json),
772 803
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
773 804
    }
…
1 unchanged lines