diff --git a/README.md b/README.md index b62b024..f8e41e1 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ atgc pr resubmit --pr # append a round to one of your PRs atgc pr edit # change a PR's title or body (--title/--body) atgc pr close # close a PR (yours, or one against a repo you own) atgc pr reopen # reopen a closed PR +atgc pr merge # land a PR on its target branch (repo owner's act) ``` To interact with PRs more generally: @@ -111,6 +112,7 @@ shows the chain when there is one. ``` atgc stack create # one PR per commit of base..HEAD, chained bottom-up atgc stack resubmit # reconcile the records after a rebase/amend/reorder +atgc stack merge # land the stack bottom-up (--through N stops partway) atgc stack view # the current branch's stack, top to bottom ``` diff --git a/TODO.md b/TODO.md index ef12fdf..f230174 100644 --- a/TODO.md +++ b/TODO.md @@ -347,8 +347,17 @@ *your* PDS. Now classified like every other reference, and a pull that is not yours is refused by name rather than by 404 - [x] `pr resubmit` — append a round (gzip patch) to an existing PR -- [ ] `pr merge` — knot XRPC (rpc:sh.tangled.repo.merge + mergeCheck first); - needs service auth minted by the PDS +- [x] `pr merge` — knot XRPC (`sh.tangled.repo.merge`, `mergeCheck` first), + authorized by a service-auth token the PDS mints per call + (`crate::knot::xrpc`, the shared spelling of the dance `repo create` + grew inline). Owner-only: `own_repo_facts` doubles as the ownership + check and the knot/name lookup, off the owner's own PDS. After the + knot merges, a merged status record is written per landed pull — the + knot moved the branch, and without the records every listing keeps + calling the pulls open. A stacked pull is refused toward `stack merge` +- [ ] `pr merge`/`stack merge` are owner-only; Tangled's web also lets a + collaborator with repo:push merge. Needs the knot ACL query the + close/reopen standing check also wants - [ ] `pr checks` — pipeline status for the PR's latest round ## stack (stacked pull requests) @@ -447,6 +456,18 @@ warn where the stack command is better. pull in an owned repo with no status record anywhere is open, not `?`. Scoped to authored-and-owned on purpose — anyone else's pull can have state sitting in a PDS the read never saw +- [x] `stack merge` — the sub-stack from the bottom `--through` a position + (default: all of it) as one combined patch, the way Tangled's own + button lands a stack: `mergeCheck` first (also what `--dry-run` + stops after), one `merge`, then a merged status record per landed + pull, bottom up. Already-merged members contribute nothing; anything + not cleanly open is refused. Proven live on atgc-stack-lab, both + halves: `--through 1` landed the bottom alone, and after a rebase the + resubmit reconciled the survivors around the merged member as the + chain's anchor — the one fate that had only unit tests — then a full + `stack merge` landed the rest. The complete lifecycle (create, view, + amend, resubmit, checkout, partial merge, anchor reconcile, final + merge) has now run CLI-only against real records - [ ] `pr close`/`pr reopen` warn when pulls sit above the target - [ ] `pr view`/`pr list` still print `?` for the same fresh pulls that `stack view` now calls open — they read through `gather` directly, diff --git a/src/knot.rs b/src/knot.rs new file mode 100644 index 0000000..eda0c3f --- /dev/null +++ b/src/knot.rs @@ -0,0 +1,80 @@ +//! Authenticated XRPC against a knot. +//! +//! A knot call is nothing like the PDS writes everywhere else in atgc: the +//! record layer is not involved, and authorization is a *service-auth* +//! token — a short-lived JWT the account's own PDS mints naming the knot as +//! audience and the one method as `lxm`, which the knot then verifies +//! against the caller's signing key. The OAuth session's `rpc:*?aud=*` +//! scopes are what allow the PDS to mint them; the merge and mergeCheck +//! scopes have been requested since before the commands existed, so no +//! re-login is needed. +//! +//! `repo create` grew the first copy of this dance inline and keeps it; +//! this module is the shared spelling for everything after it. + +use anyhow::{Context, Result, anyhow}; + +/// One authenticated procedure against `knot`, returning the response JSON. +/// +/// The token is minted fresh per call — it is audience- and method-bound, +/// so there is nothing to reuse — and errors distinguish the PDS refusing +/// to mint (a scope or session problem) from the knot refusing the call +/// (an authorization or content problem), because the fixes live in +/// different places. +pub async fn xrpc( + agent: &jacquard::client::Agent, + knot: &str, + nsid: &str, + input: &serde_json::Value, +) -> Result { + use jacquard::api::com_atproto::server::get_service_auth::GetServiceAuth; + use jacquard::common::xrpc::XrpcClient; + use jacquard::types::string::Nsid; + + let aud = format!("did:web:{knot}"); + crate::debug::log(format!("getServiceAuth on PDS: aud={aud} lxm={nsid}")); + let service_auth = agent + .send(GetServiceAuth:: { + aud: aud.into(), + exp: None, + lxm: Some(Nsid::new_owned(nsid).map_err(|e| anyhow!("bad lxm nsid {nsid}: {e}"))?), + }) + .await + .map_err(|e| { + crate::debug::dump_err("getServiceAuth transport error", &e); + anyhow!("service auth request failed: {e}") + })? + .into_output() + .map_err(|e| { + crate::debug::dump_err("getServiceAuth error response", &e); + anyhow!("the PDS refused service auth for {nsid} against {knot}: {e}") + })?; + crate::debug::log(format!( + "service auth token claims: {}", + crate::debug::jwt_claims(service_auth.token.as_str()) + )); + + let url = format!("https://{knot}/xrpc/{nsid}"); + crate::debug::log(format!(">> POST {url}\n{input}")); + let resp = crate::http::client() + .post(&url) + .bearer_auth(service_auth.token.as_str()) + .json(input) + .send() + .await + .with_context(|| format!("could not reach knot {knot}"))?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + crate::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"] + .as_str() + .or(body["error"].as_str()) + .unwrap_or(text.trim()) + ); + } + Ok(body) +} diff --git a/src/main.rs b/src/main.rs index f9a34f3..96a8419 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod git; mod http; mod identity; mod key; +mod knot; mod models; mod oauthlog; mod pds; @@ -446,6 +447,32 @@ enum StackCommand { #[arg(long)] dry_run: bool, }, + /// Merge the current branch's stack into its target branch + /// + /// Lands the stack bottom-up as one combined patch — the same merge + /// Tangled's web button performs — after the knot confirms it applies + /// 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. + /// + /// Examples: + /// atgc stack merge --dry-run + /// atgc stack merge --through 1 + /// atgc stack merge + #[command(verbatim_doc_comment)] + Merge { + /// Merge only positions 1..=N, counted from the bottom + #[arg(long, value_name = "N")] + through: Option, + /// Git remote pointing at the repo + #[arg(long, default_value = "origin")] + remote: String, + /// Run the knot's merge check and stop; nothing is merged + #[arg(long)] + dry_run: bool, + }, /// Reconcile the stack's records with the rewritten branch /// /// After a rebase, amend, reorder or drop, this matches each pull to @@ -652,6 +679,31 @@ enum PrCommand { #[arg(long, value_name = "BYTES", default_value_t = review::DEFAULT_MAX_BYTES)] max_bytes: u64, }, + /// 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`. + /// + /// Examples: + /// atgc pr merge 23 --dry-run + /// atgc pr merge 23 + #[command(verbatim_doc_comment)] + Merge { + /// The pull: a number, record key, at:// URI or Tangled URL + #[arg(value_name = "PR")] + pull: Option, + /// The pull, as a flag (same spellings) + #[arg(long = "pr", value_name = "PR", conflicts_with = "pull")] + pr: Option, + /// Git remote pointing at the repo + #[arg(long, default_value = "origin")] + remote: String, + /// Run the knot's merge check and stop; nothing is merged + #[arg(long)] + dry_run: bool, + }, /// Append a round to one of your pull requests Resubmit { /// The pull to update: its number, record key, at:// URI, or Tangled URL @@ -858,6 +910,18 @@ async fn main() -> anyhow::Result<()> { } Command::Completion { shell } => completion::completion(completion::Args { shell }), Command::Stack { command } => match command { + StackCommand::Merge { + through, + remote, + dry_run, + } => { + stack::merge(stack::MergeArgs { + through, + remote, + dry_run, + }) + .await + } StackCommand::Create { target, remote, @@ -1006,6 +1070,22 @@ async fn main() -> anyhow::Result<()> { }) .await } + PrCommand::Merge { + pull, + pr, + remote, + dry_run, + } => { + let Some(reference) = pull.or(pr) else { + anyhow::bail!("name the pull to merge: a number, record key, at:// URI or URL"); + }; + pr::merge(pr::MergeArgs { + pr: reference, + remote, + dry_run, + }) + .await + } PrCommand::Resubmit { pr: pr_ref, remote, diff --git a/src/pr/mod.rs b/src/pr/mod.rs index c87d07f..fbd0fe8 100644 --- a/src/pr/mod.rs +++ b/src/pr/mod.rs @@ -61,6 +61,6 @@ mod write; pub use read::{ListArgs, StatusArgs, ViewArgs, list, status, view}; pub(crate) use write::owns_repo; pub use write::{ - CommentArgs, CreateArgs, EditArgs, ResubmitArgs, StateArgs, close, comment, create, edit, - reopen, resubmit, + CommentArgs, CreateArgs, EditArgs, MergeArgs, ResubmitArgs, StateArgs, close, comment, create, + edit, merge, reopen, resubmit, }; diff --git a/src/pr/write.rs b/src/pr/write.rs index 74a5342..13fec4d 100644 --- a/src/pr/write.rs +++ b/src/pr/write.rs @@ -757,6 +757,87 @@ async fn standing_of( }) } +pub struct MergeArgs { + pub pr: String, + pub remote: String, + pub dry_run: bool, +} + +/// 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::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. +pub async fn merge(args: MergeArgs) -> Result<()> { + let selection = crate::account::select().await?; + selection.announce(); + let me = selection.did.clone(); + + let target = resolve_pull_ref(&args.pr, &me, &args.remote).await?; + let uri = target.uri(); + let author_pds = auth::pds_from_did_doc(&target.author) + .await + .ok_or_else(|| { + anyhow::anyhow!("no PDS endpoint in the DID document for {}", target.author) + })?; + let (value, _cid) = crate::pds::get_record( + &author_pds, + &target.author, + crate::models::PULL_NSID, + &target.rkey, + ) + .await?; + let title = value["title"].as_str().unwrap_or("(untitled)").to_string(); + let repo_did = value["target"]["repo"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("{title} names no target repo"))? + .to_string(); + + // The stacked refusal needs the listing — being depended on is written + // in other records, not this one. + let rows = super::read::repo_rows(super::read::Source::Auto, &repo_did).await?; + let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect(); + if let Some(chain) = crate::stack::chain_containing(&items, &uri)? { + bail!( + "{title} is part of a stack of {} — Tangled merges a stack member together \ + with everything beneath it\n\ + `atgc stack merge` is the command for that (--through picks how far up)", + chain.members.len() + ); + } + + let my_pds = auth::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::stack::own_repo_facts(&my_pds, &me, &repo_did).await?; + let patch = + crate::stack::latest_round_patch(&author_pds, &target.author, &value, &title).await?; + let target_branch = value["target"]["branch"] + .as_str() + .unwrap_or("main") + .to_string(); + + println!("pr: {title} ({})", target.rkey); + println!("target: {repo_did} branch {target_branch}"); + let plan = crate::stack::MergePlan { + knot, + owner_did: me.clone(), + repo_name, + repo_did, + target_branch, + patch, + title, + body: value["body"].as_str().map(str::to_string), + pulls: vec![uri], + }; + let agent = auth::agent_for_did(&me).await?; + crate::stack::run_merge(&agent, &plan, args.dry_run).await +} + pub struct StateArgs { /// The pull to act on: its number, record key, at:// URI or Tangled URL. pub pr: Option, diff --git a/src/stack/mod.rs b/src/stack/mod.rs index 02d59ba..7d4421f 100644 --- a/src/stack/mod.rs +++ b/src/stack/mod.rs @@ -26,7 +26,8 @@ mod read; mod write; pub use read::{ViewArgs, view}; -pub use write::{CreateArgs, ResubmitArgs, create, resubmit}; +pub use write::{CreateArgs, MergeArgs, ResubmitArgs, create, merge, resubmit}; +pub(crate) use write::{MergePlan, latest_round_patch, own_repo_facts, run_merge}; /// A stack, assembled from a repo's pull listing. #[derive(Debug)] diff --git a/src/stack/write.rs b/src/stack/write.rs index 91bda7d..7372c98 100644 --- a/src/stack/write.rs +++ b/src/stack/write.rs @@ -555,29 +555,7 @@ async fn old_member( let value = &item["value"]; let title = value["title"].as_str().unwrap_or("(untitled)").to_string(); let rounds = value["rounds"].as_array().map(|r| r.len()).unwrap_or(0); - let round = value["rounds"] - .as_array() - .and_then(|r| r.last()) - .ok_or_else(|| { - anyhow::anyhow!("{title} ({rkey}) has no rounds; not a stack atgc can reconcile") - })?; - let cid = round["patchBlob"]["ref"]["$link"].as_str().ok_or_else(|| { - anyhow::anyhow!("{title} ({rkey})'s latest round has no patch blob reference") - })?; - let size = round["patchBlob"]["size"].as_u64().unwrap_or(0); - if size > crate::review::DEFAULT_MAX_BYTES { - bail!( - "{title} ({rkey})'s latest patch is {size} bytes compressed, over the \ - {}-byte limit", - crate::review::DEFAULT_MAX_BYTES - ); - } - let resp = crate::pds::get_blob(pds, did, cid).await?; - let bytes = resp - .bytes() - .await - .map_err(|e| anyhow::anyhow!("could not read {title} ({rkey})'s latest patch: {e}"))?; - let latest_patch = crate::review::decompress(&bytes, crate::review::DEFAULT_MAX_BYTES)?; + let latest_patch = latest_round_patch(pds, did, value, &format!("{title} ({rkey})")).await?; Ok(OldMember { change_id: change_id_header(&latest_patch), dependent_on: value["dependentOn"].as_str().map(str::to_string), @@ -868,6 +846,313 @@ pub async fn resubmit(args: ResubmitArgs) -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// `stack merge` +// --------------------------------------------------------------------------- + +pub(crate) const MERGE_NSID: &str = "sh.tangled.repo.merge"; +pub(crate) const MERGE_CHECK_NSID: &str = "sh.tangled.repo.mergeCheck"; + +pub struct MergeArgs { + pub through: Option, + pub remote: String, + pub dry_run: bool, +} + +/// Everything one knot merge needs, however it was assembled. Shared with +/// `pr merge`, whose plan is a stack of one. +pub(crate) struct MergePlan { + pub knot: String, + pub owner_did: String, + pub repo_name: String, + pub repo_did: String, + pub target_branch: String, + /// Combined patch, bottom first — the text the knot applies as one + /// merge, exactly as Tangled's own stacked merge sends it. + pub patch: String, + pub title: String, + pub body: Option, + /// The pulls this merge lands, bottom first, to be marked merged. + 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(crate) async fn own_repo_facts( + pds: &str, + did: &str, + repo_did: &str, +) -> Result<(String, String)> { + let (records, _truncated) = + crate::pds::list_records_all(pds, did, crate::models::REPO_NSID, |page| { + page.iter() + .any(|r| r["value"]["repoDid"].as_str() == Some(repo_did)) + }) + .await?; + for record in &records { + if record["value"]["repoDid"].as_str() != Some(repo_did) { + continue; + } + let knot = record["value"]["knot"].as_str().unwrap_or_default(); + let name = record["value"]["name"] + .as_str() + .or_else(|| record["uri"].as_str().and_then(|u| u.rsplit('/').next())) + .unwrap_or_default(); + 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())); + } + bail!( + "no sh.tangled.repo record for {repo_did} in {did}'s PDS — merging is the repo \ + owner's act, and this account does not appear to own the repo" + ); +} + +/// A record's latest round reduced to its patch text — public reads from +/// the author's PDS, bounded the way `pr diff` bounds them. `label` names +/// the pull in failures. +pub(crate) async fn latest_round_patch( + pds: &str, + did: &str, + value: &serde_json::Value, + label: &str, +) -> Result { + let round = value["rounds"] + .as_array() + .and_then(|r| r.last()) + .ok_or_else(|| anyhow::anyhow!("{label} has no rounds; nothing to read a patch from"))?; + let cid = round["patchBlob"]["ref"]["$link"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("{label}'s latest round has no patch blob reference"))?; + let size = round["patchBlob"]["size"].as_u64().unwrap_or(0); + if size > crate::review::DEFAULT_MAX_BYTES { + bail!( + "{label}'s latest patch is {size} bytes compressed, over the {}-byte limit", + crate::review::DEFAULT_MAX_BYTES + ); + } + let resp = crate::pds::get_blob(pds, did, cid).await?; + let bytes = resp + .bytes() + .await + .map_err(|e| anyhow::anyhow!("could not read {label}'s latest patch: {e}"))?; + crate::review::decompress(&bytes, crate::review::DEFAULT_MAX_BYTES) +} + +/// 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 +/// listing would keep calling these pulls open. +pub(crate) async fn run_merge( + agent: &jacquard::client::Agent, + plan: &MergePlan, + dry_run: bool, +) -> Result<()> { + use crate::models::{PULL_STATUS_NSID, PullState, PullStatus}; + + let check_input = serde_json::json!({ + "did": plan.owner_did, + "name": plan.repo_name, + "branch": plan.target_branch, + "patch": plan.patch, + "repo": plan.repo_did, + }); + let check = crate::knot::xrpc(agent, &plan.knot, MERGE_CHECK_NSID, &check_input).await?; + if check["is_conflicted"].as_bool().unwrap_or(false) { + let mut lines = String::new(); + if let Some(conflicts) = check["conflicts"].as_array() { + for c in conflicts { + lines.push_str(&format!( + " {} {}\n", + c["filename"].as_str().unwrap_or("?"), + c["reason"].as_str().unwrap_or(""), + )); + } + } + bail!( + "the knot reports this merge would conflict with {}:\n{lines}\ + rebase the branch, `stack resubmit`, and try again", + plan.target_branch, + ); + } + println!( + "check: clean against {} on {}", + plan.target_branch, plan.knot + ); + if dry_run { + println!("dry run; nothing merged"); + return Ok(()); + } + + let who = auth::Identity::fetch(&plan.owner_did).await; + let author_name = who + .handle + .map(|h| format!("@{h}")) + .unwrap_or_else(|| plan.owner_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, + }); + if let Some(body) = &plan.body { + merge_input["commitBody"] = serde_json::json!(body); + } + crate::knot::xrpc(agent, &plan.knot, MERGE_NSID, &merge_input).await?; + println!( + "merged {} pull(s) into {}", + plan.pulls.len(), + plan.target_branch + ); + + // The knot's branch moved; now say so in records. One status per pull, + // bottom first. A failure partway leaves pulls the knot merged still + // reading open — rerunning `pr close` is not the fix (it refuses to + // touch merged state), so name the leftovers precisely. + for (i, uri) in plan.pulls.iter().enumerate() { + let record = PullStatus { + record_type: PULL_STATUS_NSID.to_string(), + pull: uri.clone(), + status: PullState::Merged.token().to_string(), + created_at: Datetime::now(), + }; + use jacquard::client::AgentSessionExt; + if let Err(e) = agent.create_record(record, None).await { + crate::debug::dump_err("createRecord error", &e); + let remaining: Vec<&str> = plan.pulls[i..].iter().map(String::as_str).collect(); + bail!( + "the knot merged the patch, but writing the merged status failed at {uri}: {e}\n\ + these pulls are merged on the branch and still read open in listings:\n {}\n\ + rerun `stack merge` is not the fix — Tangled's web UI can mark them, or retry \ + once the PDS is reachable", + remaining.join("\n ") + ); + } + println!("status merged -> {uri}"); + } + Ok(()) +} + +/// Merge the current branch's stack — the whole of it, or `--through` a +/// position counted from the bottom — as one combined patch, the way +/// Tangled itself lands a stack. +pub async fn merge(args: MergeArgs) -> Result<()> { + let selection = crate::account::select().await?; + selection.announce(); + let me = selection.did.clone(); + + let branch = git::current_branch()?; + let remote_url = git::remote_url(&args.remote)?; + let repo = resolve::repo_ref(&remote_url).await?; + + let rows = listing::repo_rows(listing::Source::Auto, &repo.did).await?; + let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect(); + let Some(mine) = listing::for_branch(items.iter().copied(), &branch) else { + bail!("no pull request found for branch {branch}; nothing to merge"); + }; + let start_uri = mine["uri"].as_str().unwrap_or_default(); + let Some(chain) = super::chain_containing(&items, start_uri)? else { + bail!( + "branch {branch}'s pull request is not stacked\n\ + `atgc pr merge` lands a single pull" + ); + }; + for member in &chain.members { + let uri = member["uri"].as_str().unwrap_or_default(); + if !uri.starts_with(&format!("at://{me}/")) { + bail!("{uri} is not {me}'s pull; merging somebody else's stack is not built yet"); + } + } + + let total = chain.members.len(); + let through = args.through.unwrap_or(total); + if through == 0 || through > total { + bail!("--through {through} is out of range; the stack has {total} pulls (1 = bottom)"); + } + + let pds = auth::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 state_of = |member_uri: &str| -> String { + rows.iter() + .find(|r| r.item["uri"].as_str() == Some(member_uri)) + .map(|r| r.state.clone()) + .unwrap_or_else(|| "?".to_string()) + }; + + // Bottom through `through`: merged members contribute nothing (their + // commits are already on the target), anything not cleanly open is + // refused, and the rest are reduced to their latest round's patch. + let mut patches: Vec = Vec::new(); + let mut landing: Vec = Vec::new(); + for member in &chain.members[..through] { + let uri = member["uri"].as_str().unwrap_or_default().to_string(); + let title = member["value"]["title"].as_str().unwrap_or("(untitled)"); + match state_of(&uri).as_str() { + "merged" => continue, + "open" => {} + state => bail!( + "{title} ({uri}) is {state}; a stack merges bottom-up through open pulls only" + ), + } + let old = old_member(&pds, &me, member, "open").await?; + patches.push(old.latest_patch); + landing.push(uri); + } + if landing.is_empty() { + bail!("everything at or below position {through} is already merged; nothing to do"); + } + + let top = &chain.members[through - 1]; + let target_branch = top["value"]["target"]["branch"] + .as_str() + .unwrap_or("main") + .to_string(); + let plan = MergePlan { + knot, + owner_did: me.clone(), + repo_name, + repo_did: repo.did.clone(), + target_branch, + patch: patches.join("\n"), + title: top["value"]["title"] + .as_str() + .unwrap_or("(untitled)") + .to_string(), + body: top["value"]["body"].as_str().map(str::to_string), + pulls: landing, + }; + + println!( + "merge: {} of {total} pull(s), bottom first, into {} as one commit series", + plan.pulls.len(), + plan.target_branch + ); + for uri in &plan.pulls { + println!(" {uri}"); + } + let agent = auth::agent_for_did(&me).await?; + run_merge(&agent, &plan, args.dry_run).await?; + if !args.dry_run { + println!( + "\nnext: git fetch {r} && git rebase {r}/{t} — then `atgc stack resubmit` \ + reconciles the survivors above the merge", + r = args.remote, + t = plan.target_branch, + ); + } + Ok(()) +} + /// One member's record, fresh from the PDS at write time — the listing that /// planned the reconcile is not the copy to mutate. async fn fetch_member(pds: &str, did: &str, rkey: &str) -> Result<(Pull, Option)> {