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 |