From 7ee7f4b269069c42edb28f28b13da62f86fc9ba9 Mon Sep 17 00:00:00 2001 From: Will Date: Wed, 29 Jul 2026 11:28:05 +0000 Subject: [PATCH] knot2: create fork pull request links on push Signed-off-by: Will --- knot2/crates/knot-postreceive/src/lib.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- knot2/crates/knot-postreceive/tests/post_receive.rs | 35 +++++++++++++++++++++++++++++++++-- 2 file(s) changed, 99 insertion(s)(+), 17 deletion(s)(-) diff --git a/knot2/crates/knot-postreceive/src/lib.rs b/knot2/crates/knot-postreceive/src/lib.rs --- a/knot2/crates/knot-postreceive/src/lib.rs +++ b/knot2/crates/knot-postreceive/src/lib.rs @@ -8,7 +8,7 @@ use knot_git::{EntryKind, Haves, RefUpdate, Repo, Wants}; use knot_messages::{CiLogsKey, PushMessages, UrlKey}; use knot_types::{ AccountDid, AppviewEndpoint, BranchName, ChangedFiles, CiLogsAddr, Email, Handle, Listing, Oid, - OwnerDid, PushOptions, RefName, RefTransition, RepoDid, RepoPath, RepoRkey, + OriginUrl, OwnerDid, PushOptions, RefName, RefTransition, RepoDid, RepoPath, RepoRkey, }; use knot_workflow::{Compiled, RawWorkflow, Trigger, WorkflowName}; use url::Url; @@ -125,7 +125,8 @@ reservation.fulfill(&event); let pull_link = match (transition, context.pull) { (RefTransition::Create { .. }, Some(link)) => { - pull_request_message(repo, link, name, context.messages).unwrap_or_default() + pull_request_message(repo, link, name, context.messages, &context.actor.repo) + .unwrap_or_default() } _ => Vec::new(), }; @@ -166,6 +167,7 @@ repo: &Repo, link: &PullLink, name: &RefName, messages: &PushMessages, + repo_did: &RepoDid, ) -> Option> { let branch = branch_short(name)?; let default_ref = repo.default_branch()?; @@ -174,37 +176,86 @@ if branch == default { return None; } repo.find_ref(&default_ref).ok().flatten()?; - if repo.origin_url().is_some() { - return None; - } - let url = pull_url( - &link.appview, - &link.owner, - &link.rkey, - &SourceBranch(branch), - &TargetBranch(default), - )?; + + let url = match repo.origin_url() { + Some(remote) => fork_pull_url( + &link.appview, + &SourceBranch(branch), + &TargetBranch(default), + remote, + repo_did, + )?, + None => branch_pull_url( + &link.appview, + &link.owner, + &link.rkey, + &SourceBranch(branch), + &TargetBranch(default), + )?, + }; + Some(messages.pull_request.lines(|UrlKey::Url| url.to_string())) } -fn pull_url( +fn branch_pull_url( appview: &AppviewEndpoint, owner: &OwnerLabel, - repo: &RepoRkey, + repo_rkey: &RepoRkey, source: &SourceBranch, target: &TargetBranch, ) -> Option { let mut url = Url::parse(appview.as_str()).ok()?; + url.path_segments_mut().ok()?.pop_if_empty().extend([ owner.as_str(), - repo.as_str(), + repo_rkey.as_str(), "pulls", "new", ]); + url.query_pairs_mut() .append_pair("source", "branch") .append_pair("sourceBranch", source.0.as_str()) .append_pair("targetBranch", target.0.as_str()); + + Some(url) +} + +fn fork_pull_url( + appview: &AppviewEndpoint, + source: &SourceBranch, + target: &TargetBranch, + remote: OriginUrl, + repo_did: &RepoDid, +) -> Option { + let remote_url = Url::parse(remote.as_str()).ok()?; + + // TODO: We need to handle file schemes. For now though if the remote is a + // file scheme a fork PR link won't be created. + match remote_url.scheme() { + "http" | "https" => (), + _ => return None, + } + + let paths: Vec<&str> = remote_url + .path_segments() + .map(|segments| segments.collect()) + .unwrap_or_default(); + + let mut url = Url::parse(appview.as_str()).ok()?; + + url.path_segments_mut() + .ok()? + .pop_if_empty() + .extend(paths) + .extend(["pulls", "new"]); + + url.query_pairs_mut() + .append_pair("source", "fork") + .append_pair("sourceBranch", source.0.as_str()) + .append_pair("targetBranch", target.0.as_str()) + .append_pair("fork", repo_did.as_str()); + Some(url) } diff --git a/knot2/crates/knot-postreceive/tests/post_receive.rs b/knot2/crates/knot-postreceive/tests/post_receive.rs --- a/knot2/crates/knot-postreceive/tests/post_receive.rs +++ b/knot2/crates/knot-postreceive/tests/post_receive.rs @@ -611,6 +611,35 @@ "{link}" ); assert!(link.contains("sourceBranch=feature"), "{link}"); assert!(link.contains("targetBranch=main"), "{link}"); + assert!(link.contains("source=branch"), "{link}"); +} + +#[test] +fn a_new_branch_with_an_origin_remote_yields_a_pull_request_link() { + let world = world(); + world + .repo + .set_origin_url(&OriginUrl::new("https://oyster.cafe/did:plc:squid/anemone")) + .unwrap(); + let log = log(); + let head = create_feature(&world); + + let applied = created("feature", head); + let messages = run(&world, &log, &applied, &Ci::Skip, Some(&pull())); + + let link = messages + .iter() + .find(|line| line.contains("/pulls/new")) + .expect("pull-request link is offered for a new branch on a fork with origin remote"); + + assert!( + link.contains("https://tangled.test/did:plc:squid/anemone/pulls/new"), + "{link}" + ); + assert!(link.contains("sourceBranch=feature"), "{link}"); + assert!(link.contains("targetBranch=main"), "{link}"); + assert!(link.contains("source=fork"), "{link}"); + assert!(link.contains("fork=did%3Aplc%3Alimpet"), "{link}"); } #[test] @@ -633,10 +662,12 @@ old, new: oid(w, "HEAD"), }] }), - ("new branch on a fork with an origin remote", |w| { + ("new branch with a file origin remote", |w| { let head = create_feature(w); w.repo - .set_origin_url(&OriginUrl::new("https://oyster.cafe/did:plc:squid/anemone")) + .set_origin_url(&OriginUrl::new( + "file://git/repos/oyster.cafe/did:plc:squid/anemone", + )) .unwrap(); created("feature", head) }), -- tangled.sh