diff --git a/src/clients/atproto/did.rs b/src/clients/atproto/did.rs index 82b22ad..da287ad 100644 --- a/src/clients/atproto/did.rs +++ b/src/clients/atproto/did.rs @@ -1,20 +1,28 @@ -//! The DID document, and the two things atgc reads out of it. +//! The DID document, and the three things atgc reads out of it. //! //! A DID document says where an account's PDS is and what handle it claims. //! Both are needed before anything else can happen — the PDS is the only //! party that can serve or write that account's records — and neither is //! discoverable any other way. //! -//! [`pds_from_did_doc`] and [`handle_from_did_doc`] are the whole surface. -//! They resolve `did:plc:` through plc.directory and `did:web:` through the -//! host itself, and both answer `None` rather than failing: a caller that -//! cannot find a PDS has a different thing to say than one whose lookup -//! errored. +//! A *repo* has a DID too, and its document is a different shape: no handle, +//! and one service naming the knot that holds the git repository. +//! [`knot_from_did_doc`] is that third reader, and it is the only lookup that +//! answers "where does this repo live" for a repo this account does not own +//! — see [`crate::docs::architecture`]. +//! +//! [`pds_from_did_doc`], [`handle_from_did_doc`] and [`knot_from_did_doc`] +//! are the whole surface. They resolve `did:plc:` through plc.directory and +//! `did:web:` through the host itself, and all answer `None` rather than +//! failing: a caller that cannot find a PDS has a different thing to say than +//! one whose lookup errored. //! //! Whether a string is a DID at all is [`crate::lexicon::identity`], which //! answers without a round trip. Ask that first. use jacquard::common::types::did_doc::DidDocument; +use jacquard::common::types::string::AtprotoStr; +use jacquard::common::types::value::Data; /// What we can learn about the logged-in account, best-effort. pub struct Identity { @@ -234,9 +242,86 @@ pub(crate) fn handle_from_doc(doc: &DidDocument) -> Option { Some(doc.handles().first()?.as_str().to_string()) } +/// The knot holding a repo, from the *repo's* own DID document. +/// +/// The one way to learn where a repo lives that does not go through an +/// account: a repo record names its knot, but that record sits in the +/// owner's PDS and nothing publishes the owner of a repo DID. The document +/// the knot itself minted names the knot, whoever is asking. +pub async fn knot_from_did_doc(repo_did: &str) -> Option { + knot_from_doc(&did_doc(repo_did).await?) +} + +/// The two ways a repo DID document names its knot, newest first, each an +/// `(id, type)` pair — Tangled matches on both halves, and so does this. +/// +/// `atproto_pds` is called *legacy* by the code that reads it and is what +/// `PrepareRepoDID` still mints: a repo's DID is a PLC identity whose only +/// service was, historically, the knot standing in as its data server. The +/// `tangled_knot` spelling exists and is preferred, and a repo carrying it +/// is why this was nearly written to accept only that one — the first repo +/// tested had it and the second did not. +const KNOT_SERVICES: [(&str, &str); 2] = [ + ("tangled_knot", "TangledKnot"), + ("atproto_pds", "AtprotoPersonalDataServer"), +]; + +/// The knot's host out of a repo document's service list. +/// +/// Two things are trimmed off the endpoint. The path — `/repo/` on the +/// modern spelling — is the knot's own business and not a route atgc may +/// call; a 404 confirmed as much. The port is kept when it is not the +/// scheme's default, because what comes back is the authority +/// [`crate::clients::endpoints::knot`] turns into `https://`, and a +/// knot on 8443 is a knot. +/// +/// **Ask this only about a repo DID.** The legacy pair is exactly what an +/// *account's* PDS entry looks like, so on an account document the honest +/// answer and the dangerous one are the same string. Tangled avoids that by +/// only resolving repo identities; atgc adds a guard, because a +/// `target.repo` naming an account DID is a record anybody can write, and +/// the cost of getting it wrong is a merge aimed at somebody's PDS. A +/// document that publishes a handle is an account — a repo DID is minted +/// with `alsoKnownAs` empty and stays that way — so the legacy spelling is +/// only honoured when there is no handle to contradict it. +/// +/// `pub(crate)` for the same reason as [`pds_from_doc`]. +pub(crate) fn knot_from_doc(doc: &DidDocument) -> Option { + let services = doc.service.as_ref()?; + let endpoint = KNOT_SERVICES + .iter() + .filter(|(id, _)| *id != "atproto_pds" || doc.handles().is_empty()) + .find_map(|(id, service_type)| { + services.iter().find_map(|service| { + // A service id is a fragment, written either bare (`#id`) or + // as the whole DID URL, so it is read from the last `#`. + let named = AsRef::::as_ref(&service.id).rsplit('#').next() == Some(id); + (named && AsRef::::as_ref(&service.r#type) == *service_type) + .then_some(service.service_endpoint.as_ref()) + .flatten() + }) + })?; + // Both string spellings, exactly as [`DidDocument::pds_endpoint`] takes + // them: a service endpoint that parses as a URI arrives as one, and a + // document that spells it any other way is not answering this question. + let url = match endpoint { + Data::String(AtprotoStr::Uri(uri)) => uri.as_ref(), + Data::String(AtprotoStr::String(text)) => text.as_ref(), + _ => return None, + }; + let url = reqwest::Url::parse(url).ok()?; + let host = url.host_str()?; + // `Url::port` is `None` for the scheme's default, which is the same + // canonicalization Tangled's own `canonicalHost` performs. + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }) +} + #[cfg(test)] mod tests { - use super::{DidDocument, handle_from_doc, pds_from_doc}; + use super::{DidDocument, handle_from_doc, knot_from_doc, pds_from_doc}; /// Parse a document the way [`super::did_doc`] does, so the tests /// exercise the deserialization too rather than a hand-built struct. @@ -261,9 +346,87 @@ mod tests { "/tests/fixtures/did_doc_web.json" )); + /// A real *repo* document: `permadeath.com/atgc`'s own DID, which the + /// knot minted. No handle, one `TangledKnot` service — the whole shape + /// the third reader exists for. + /// + /// Captured with: + /// `curl https://plc.directory/did:plc:gspkabpde4kx47fj3bhiwrms` + const DID_DOC_REPO: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/did_doc_repo.json" + )); + + /// The other real repo document, and the more common one: + /// `tangled.org/core`, whose knot names itself with the `atproto_pds` + /// service the minting code still writes. Both are current — this is not + /// an old repo — so a reader that takes only the first shape works on + /// some repos and not others, which is how it shipped for an afternoon. + /// + /// Captured with: + /// `curl https://plc.directory/did:plc:j5hmlfdrwkvtxm7cjmu7j2is` + const DID_DOC_REPO_LEGACY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/did_doc_repo_legacy.json" + )); + fn doc() -> DidDocument { parse(DID_DOC) } + + /// A repo's document answers "which knot holds this" and nothing else, + /// and the answer is the endpoint's *host*: the path is a route into the + /// knot that atgc never calls, and leaving it on would build + /// `https://knot1.tangled.sh/repo/xd5…/xrpc/sh.tangled.repo.merge`. + /// + /// Both spellings, because both are live. The one that reads as legacy in + /// Tangled's source is the one its knots mint today, and a reader that + /// took only `TangledKnot` refused `tangled.org/core` while accepting + /// this project's own repo. + #[test] + fn a_repo_document_names_the_knot_holding_it() { + let repo = parse(DID_DOC_REPO); + assert_eq!( + knot_from_doc(&repo).as_deref(), + Some("knot1.tangled.sh"), + "the host, without the endpoint's path", + ); + assert_eq!(pds_from_doc(&repo), None, "a repo has no PDS"); + assert_eq!(handle_from_doc(&repo), None, "a repo has no handle"); + + assert_eq!( + knot_from_doc(&parse(DID_DOC_REPO_LEGACY)).as_deref(), + Some("knot1.tangled.sh"), + "the atproto_pds spelling is a knot on a repo DID", + ); + } + + /// The guard on that second spelling: on an *account* the same service is + /// a real PDS, and reading it as a knot would aim a merge at somebody's + /// data server. A published handle is what tells the two apart — a repo + /// DID is minted with `alsoKnownAs` empty — and a knot named the modern + /// way is taken from either, since nothing else claims that type. + #[test] + fn an_accounts_pds_is_not_mistaken_for_a_knot() { + assert_eq!(knot_from_doc(&doc()), None, "an account is not a knot"); + + let handled_knot = parse( + r##"{ + "id": "did:plc:whoever", + "alsoKnownAs": ["at://alice.example.com"], + "service": [{ + "id": "#tangled_knot", + "type": "TangledKnot", + "serviceEndpoint": "https://knot.example:8443/repo/abc" + }] + }"##, + ); + assert_eq!( + knot_from_doc(&handled_knot).as_deref(), + Some("knot.example:8443"), + "a non-default port belongs to the authority", + ); + } /// A `did:web` document parses through exactly the same readers as a /// `did:plc` one — nothing in them is method-specific — and this one is /// a knot rather than an account, so both readers must decline it diff --git a/src/clients/tangled/knot.rs b/src/clients/tangled/knot.rs index 4e31a3e..55634e4 100644 --- a/src/clients/tangled/knot.rs +++ b/src/clients/tangled/knot.rs @@ -68,13 +68,57 @@ pub async fn xrpc( crate::logging::debug::log(format!("<< {status}\n{text}")); let body: serde_json::Value = serde_json::from_str(&text).unwrap_or_default(); if !status.is_success() { - anyhow::bail!( - "{knot} refused {nsid} ({status}): {}", - body["message"] + return Err(anyhow::Error::new(Refused { + knot: knot.to_string(), + nsid: nsid.to_string(), + status, + tag: body["error"].as_str().unwrap_or_default().to_string(), + detail: body["message"] .as_str() .or(body["error"].as_str()) .unwrap_or(text.trim()) - ); + .to_string(), + })); } Ok(body) } + +/// A knot that answered, and said no. +/// +/// Typed rather than formatted straight into an `anyhow!`, because one +/// caller needs the [`tag`](Refused::tag): a knot spells its refusals with a +/// machine-readable one — `AccessControl`, `RepoNotFound`, `MergeConflict` — +/// beside the prose, and "you have no push access here" deserves a different +/// thing said next than "that repo is not on this knot". [`Display`] renders +/// exactly the sentence this used to `bail!`, so every other caller is +/// unchanged. +/// +/// [`Display`]: std::fmt::Display +#[derive(Debug)] +pub struct Refused { + pub knot: String, + pub nsid: String, + /// Kept as reqwest's type so the rendered sentence still reads + /// `(401 Unauthorized)` rather than `(401)`. + pub status: reqwest::StatusCode, + /// The lexicon's error name, or empty for a refusal that carried none. + pub tag: String, + /// The human half: the knot's `message`, its `error` when there was no + /// message, or the raw body when it sent neither. + pub detail: String, +} + +impl std::fmt::Display for Refused { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Refused { + knot, + nsid, + status, + detail, + .. + } = self; + write!(f, "{knot} refused {nsid} ({status}): {detail}") + } +} + +impl std::error::Error for Refused {} diff --git a/src/cmd/pr/mod.rs b/src/cmd/pr/mod.rs index 7eb2b88..4d98536 100644 --- a/src/cmd/pr/mod.rs +++ b/src/cmd/pr/mod.rs @@ -168,10 +168,12 @@ pub(crate) enum Command { Checkout(review::CheckoutArgs), /// Merge a pull request into its target branch /// - /// The repo owner's act, like the web's merge button: the knot checks - /// the latest round applies cleanly, merges it, and the pull is marked - /// merged. A stacked pull is refused — Tangled merges a stack member - /// together with everything beneath it, which is `atgc stack merge`. + /// The web's merge button: the knot checks the latest round applies + /// cleanly, merges it, and the pull is marked merged. Takes the push + /// access the knot takes, so the repo's owner and its collaborators can + /// both land a pull. A stacked pull is refused — Tangled merges a stack + /// member together with everything beneath it, which is + /// `atgc stack merge`. /// /// Examples: /// atgc pr merge 23 --dry-run diff --git a/src/cmd/pr/write.rs b/src/cmd/pr/write.rs index af24a14..c14d254 100644 --- a/src/cmd/pr/write.rs +++ b/src/cmd/pr/write.rs @@ -1228,12 +1228,14 @@ pub(super) struct MergedJson { /// Land one flat pull request: knot `mergeCheck`, knot `merge` with the /// latest round's patch, then a merged status record. /// -/// The owner's act, like the web's merge button: the knot call is -/// authorized by a service-auth token, and [`crate::cmd::stack::own_repo_facts`] -/// doubles as the ownership check — only the owner's PDS holds the repo -/// record that names the knot. A stacked pull is refused: Tangled merges a -/// stack member together with everything beneath it, and that is -/// `atgc stack merge`'s contract, not this command's. +/// Exactly the web's merge button: the knot call is authorized by a +/// service-auth token naming this account, and the knot decides whether this +/// account may push to the repo. atgc used to decide that itself, by whether +/// [`crate::cmd::stack::repo_facts`] found the repo record in this account's +/// own PDS, which refused every collaborator who can merge on tangled.org. +/// A stacked pull is still refused: Tangled merges a stack member together +/// with everything beneath it, and that is `atgc stack merge`'s contract, +/// not this command's. pub(super) async fn merge(args: MergeArgs) -> Result<()> { crate::term::jsonout::init(args.json); let selection = crate::config::account::select().await?; @@ -1287,7 +1289,7 @@ pub(super) async fn merge(args: MergeArgs) -> Result<()> { let my_pds = crate::clients::atproto::did::pds_from_did_doc(&me) .await .ok_or_else(|| anyhow::anyhow!("no PDS endpoint in the DID document for {me}"))?; - let (knot, repo_name) = crate::cmd::stack::own_repo_facts(&my_pds, &me, &repo_did).await?; + let facts = crate::cmd::stack::repo_facts(&my_pds, &me, &repo_did).await?; let patch = crate::cmd::stack::latest_round_patch(&author_pds, &target.author, &value, &title).await?; let target_branch = value["target"]["branch"] @@ -1300,9 +1302,13 @@ pub(super) async fn merge(args: MergeArgs) -> Result<()> { println!("target: {repo_did} branch {target_branch}"); } let plan = crate::cmd::stack::MergePlan { - knot, - owner_did: me.clone(), - repo_name, + knot: facts.knot, + actor_did: me.clone(), + // The pull's author, not whoever is merging: the merge commit + // belongs to the person whose patch it is, which is what Tangled's + // own merge sends. Only read for a patch `git am` cannot take. + author_did: target.author.clone(), + owner: facts.owner, repo_did, target_branch, patch, diff --git a/src/cmd/stack/mod.rs b/src/cmd/stack/mod.rs index 46eda6c..68342b0 100644 --- a/src/cmd/stack/mod.rs +++ b/src/cmd/stack/mod.rs @@ -25,7 +25,7 @@ use anyhow::{Result, bail}; pub(crate) mod read; pub(crate) mod write; -pub(in crate::cmd) use write::{MergePlan, latest_round_patch, own_repo_facts, run_merge}; +pub(in crate::cmd) use write::{MergePlan, latest_round_patch, repo_facts, run_merge}; /// A stack, assembled from a repo's pull listing. #[derive(Debug)] @@ -370,8 +370,9 @@ pub(crate) enum Command { /// cleanly, then marks every landed pull merged. `--through ` stops at /// position n counted from the bottom, landing only the pulls at or /// below it. Already-merged members contribute nothing and are skipped. - /// The repo owner's act: the knot call is authorized by a service-auth - /// token minted by your PDS. + /// The knot call is authorized by a service-auth token minted by your + /// PDS, and the knot takes it from anyone it lets push — the repo's + /// owner, or a collaborator landing their own stack. /// /// Examples: /// atgc stack merge --dry-run diff --git a/src/cmd/stack/write.rs b/src/cmd/stack/write.rs index d41e7a2..bacaa7f 100644 --- a/src/cmd/stack/write.rs +++ b/src/cmd/stack/write.rs @@ -1527,8 +1527,18 @@ pub(super) struct MergedJson { /// `pr merge`, whose plan is a stack of one. pub(in crate::cmd) struct MergePlan { pub knot: String, - pub owner_did: String, - pub repo_name: String, + /// The account making the call: it signs the knot's service-auth token, + /// and the merged status records land in *its* PDS. Not necessarily the + /// repo's owner — see [`repo_facts`]. + pub actor_did: String, + /// Who the merge commit is authored by, which Tangled makes the pull's + /// author rather than whoever pressed the button. Only read for a patch + /// that is not a mailbox: `git am` carries its own authorship. + pub author_did: String, + /// How the repo's owner names it, when this account can find that out. + /// `None` costs nothing on a current knot and is only a problem on one + /// old enough to route by owner and name — see [`RepoOwner`]. + pub owner: Option, pub repo_did: String, pub target_branch: String, /// Combined patch, bottom first — the text the knot applies as one @@ -1540,16 +1550,40 @@ pub(in crate::cmd) struct MergePlan { pub pulls: Vec, } -/// The knot and repo name behind a repo DID, read from the acting account's -/// own `sh.tangled.repo` records — which is also the ownership check, since -/// only the owner's repository holds that record. Merging is the owner's -/// act in atgc for now, exactly as `pr close` limits itself to standings -/// both indexers honour. -pub(in crate::cmd) async fn own_repo_facts( - pds: &str, - did: &str, - repo_did: &str, -) -> Result<(String, String)> { +/// A repo as its owner names it: the pair `sh.tangled.repo.merge` takes in +/// `did` and `name`. +/// +/// Those two fields are the *legacy* spelling. A knot advertising the +/// `repo-did-input` capability routes by `repo` — the repo's own DID — and +/// ignores both, which is why a merge no longer needs them and why not +/// having them is no longer a refusal. +pub(in crate::cmd) struct RepoOwner { + pub did: String, + pub name: String, +} + +/// Where a repo lives, and who owns it if this account can tell. +pub(in crate::cmd) struct RepoFacts { + pub knot: String, + pub owner: Option, +} + +/// The knot behind a repo DID, and the owner's name for it when that is +/// discoverable from here. +/// +/// This used to be `own_repo_facts`, and reading it out of the acting +/// account's own `sh.tangled.repo` records doubled as an ownership check: +/// no record, no merge. That check was atgc's invention. A knot authorizes +/// `sh.tangled.repo.merge` with `IsPushAllowed`, so every collaborator with +/// push access may merge — as they can on tangled.org, which sends the merge +/// under the *logged-in* account and not the owner's. Refusing here meant +/// answering a question only the knot can answer, in the negative, without +/// asking it. +/// +/// So the owner's record is now a source of two optional fields rather than +/// a gate, and the fact that must be right — which host to call — falls back +/// to the repo's own DID document, which names its knot to anyone. +pub(in crate::cmd) async fn repo_facts(pds: &str, did: &str, repo_did: &str) -> Result { let (records, _truncated) = crate::clients::atproto::pds::list_records_all( pds, did, @@ -1572,12 +1606,28 @@ pub(in crate::cmd) async fn own_repo_facts( if knot.is_empty() || name.is_empty() { bail!("the repo record for {repo_did} names no knot or no name"); } - return Ok((knot.to_string(), name.to_string())); + return Ok(RepoFacts { + knot: knot.to_string(), + owner: Some(RepoOwner { + did: did.to_string(), + name: name.to_string(), + }), + }); } - bail!( - "no sh.tangled.repo record for {repo_did} in {did}'s PDS\n\ - merging is the repo owner's act, and this account does not own the repo" - ); + + crate::logging::debug::log(format!( + "no sh.tangled.repo record for {repo_did} in {did}'s PDS; \ + asking the repo's DID document which knot holds it" + )); + let Some(knot) = crate::clients::atproto::did::knot_from_did_doc(repo_did).await else { + bail!( + "cannot tell which knot holds {repo_did}: this account has no \ + sh.tangled.repo record for it, and its DID document names no knot\n\ + a repo created before knot v1.13 has no DID of its own and no document \ + to name one; merge it from the owner's account or on tangled.org" + ); + }; + Ok(RepoFacts { knot, owner: None }) } /// A record's latest round reduced to its patch text — public reads from @@ -1660,6 +1710,51 @@ fn interpret_merge_check(check: &serde_json::Value) -> Result> { Ok(Some(lines)) } +/// Add `did` and `name` to a knot input when they are known. +/// +/// The pair a knot without the `repo-did-input` capability reads *in place +/// of* `repo`, per the `sh.tangled.repo.merge` lexicon. A current knot +/// ignores them, so they are sent when this account happens to hold the +/// owner's record and left out otherwise, rather than being something a +/// merge has to establish first. +fn name_the_owner(input: &mut serde_json::Value, plan: &MergePlan) { + let Some(owner) = &plan.owner else { + return; + }; + input["did"] = serde_json::json!(owner.did); + input["name"] = serde_json::json!(owner.name); +} + +/// Say what to do about a knot's refusal, for the one refusal that has an +/// answer worth printing. +/// +/// `AccessControl` is the knot saying this account is not on the repo's push +/// list. That is now reachable — atgc no longer decides for itself who may +/// merge — and it is worth distinguishing from the other reason a merge +/// stops, because the fix is somebody else's to make. +fn explain_refusal(error: anyhow::Error, plan: &MergePlan) -> anyhow::Error { + let Some(refusal) = error.downcast_ref::() else { + return error; + }; + if refusal.tag != "AccessControl" { + return error; + } + let owner = match &plan.owner { + Some(owner) => format!("{}'s to give", owner.did), + None => "the repo owner's to give".to_string(), + }; + crate::exit::fail( + crate::exit::Exit::Denied, + format!( + "{error}\n\ + {} authorizes a merge by push access, and this account has none on \ + {}. Push access is {owner} — as a collaborator on the repo, which is \ + not the same as the SSH key `atgc key add` registers", + plan.knot, plan.repo_did, + ), + ) +} + /// Check, merge, and record: one `mergeCheck`, one `merge`, then a merged /// status per pull. The status records are what the rest of the pull /// machinery reads — the knot changed the branch, and without them every @@ -1672,13 +1767,12 @@ pub(in crate::cmd) async fn run_merge( use crate::lexicon::tangled::{PULL_STATUS_NSID, PullState}; use tangled_lexicon::sh_tangled::repo::pull::status::{Status as PullStatus, StatusStatus}; - let check_input = serde_json::json!({ - "did": plan.owner_did, - "name": plan.repo_name, + let mut check_input = serde_json::json!({ "branch": plan.target_branch, "patch": plan.patch, "repo": plan.repo_did, }); + name_the_owner(&mut check_input, plan); let check = crate::clients::tangled::knot::xrpc(agent, &plan.knot, MERGE_CHECK_NSID, &check_input) .await?; @@ -1708,25 +1802,26 @@ pub(in crate::cmd) async fn run_merge( return Ok(()); } - let who = crate::clients::atproto::did::Identity::fetch(&plan.owner_did).await; + let who = crate::clients::atproto::did::Identity::fetch(&plan.author_did).await; let author_name = who .handle .map(|h| format!("@{h}")) - .unwrap_or_else(|| plan.owner_did.clone()); + .unwrap_or_else(|| plan.author_did.clone()); let mut merge_input = serde_json::json!({ - "did": plan.owner_did, - "name": plan.repo_name, "branch": plan.target_branch, "patch": plan.patch, "repo": plan.repo_did, "commitMessage": plan.title, "authorName": author_name, - "authorEmail": plan.owner_did, + "authorEmail": plan.author_did, }); + name_the_owner(&mut merge_input, plan); if let Some(body) = &plan.body { merge_input["commitBody"] = serde_json::json!(body); } - crate::clients::tangled::knot::xrpc(agent, &plan.knot, MERGE_NSID, &merge_input).await?; + crate::clients::tangled::knot::xrpc(agent, &plan.knot, MERGE_NSID, &merge_input) + .await + .map_err(|e| explain_refusal(e, plan))?; if !quiet { println!( "merged {} pull(s) into {}", @@ -1755,7 +1850,7 @@ pub(in crate::cmd) async fn run_merge( }); } if let Err(e) = - crate::clients::atproto::record::batch(agent, "merged status", &plan.owner_did, ops, None) + crate::clients::atproto::record::batch(agent, "merged status", &plan.actor_did, ops, None) .await { bail!( @@ -1830,7 +1925,7 @@ pub(in crate::cmd) async fn merge(args: MergeArgs) -> Result<()> { let pds = crate::clients::atproto::did::pds_from_did_doc(&me) .await .ok_or_else(|| anyhow::anyhow!("no PDS endpoint in the DID document for {me}"))?; - let (knot, repo_name) = own_repo_facts(&pds, &me, &repo.did).await?; + let facts = repo_facts(&pds, &me, &repo.did).await?; // Bottom through `through`: merged members contribute nothing (their // commits are already on the target), anything not cleanly open is @@ -1867,9 +1962,13 @@ pub(in crate::cmd) async fn merge(args: MergeArgs) -> Result<()> { let top = &chain.members[through - 1]; let target_branch = target_branch_of(top)?; let plan = MergePlan { - knot, - owner_did: me.clone(), - repo_name, + knot: facts.knot, + actor_did: me.clone(), + // A stack is the acting account's own chain — `own_chain` above + // refuses anybody else's — so the pulls' author is this account + // whether or not it owns the repo it is merging into. + author_did: me.clone(), + owner: facts.owner, repo_did: repo.did.clone(), target_branch, patch: patches.join("\n"), diff --git a/tests/fixtures/did_doc_repo.json b/tests/fixtures/did_doc_repo.json new file mode 100644 index 0000000..eb2878e --- /dev/null +++ b/tests/fixtures/did_doc_repo.json @@ -0,0 +1,16 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1" + ], + "id": "did:plc:gspkabpde4kx47fj3bhiwrms", + "alsoKnownAs": [], + "verificationMethod": [], + "service": [ + { + "id": "#tangled_knot", + "type": "TangledKnot", + "serviceEndpoint": "https://knot1.tangled.sh/repo/xd5wg4cdgxg7ezwlg42ezx5qeu" + } + ] +} diff --git a/tests/fixtures/did_doc_repo_legacy.json b/tests/fixtures/did_doc_repo_legacy.json new file mode 100644 index 0000000..0054293 --- /dev/null +++ b/tests/fixtures/did_doc_repo_legacy.json @@ -0,0 +1,24 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1" + ], + "id": "did:plc:j5hmlfdrwkvtxm7cjmu7j2is", + "alsoKnownAs": [], + "verificationMethod": [ + { + "id": "did:plc:j5hmlfdrwkvtxm7cjmu7j2is#atproto", + "type": "Multikey", + "controller": "did:plc:j5hmlfdrwkvtxm7cjmu7j2is", + "publicKeyMultibase": "zQ3shWH1kjAyXTdAVkyGDR8nPmRmtR5ANuz6eWHwSxTXMAL8P" + } + ], + "service": [ + { + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": "https://knot1.tangled.sh" + } + ] +}