diff --git a/crates/jayjay-core/src/repo/github.rs b/crates/jayjay-core/src/repo/github.rs index a9496c2..b5cbfa2 100644 --- a/crates/jayjay-core/src/repo/github.rs +++ b/crates/jayjay-core/src/repo/github.rs @@ -4,6 +4,8 @@ use super::environment::gh_binary; use super::Repo; use crate::types::{ChecksStatus, PrInfo, PrState}; +const GITHUB_HOST: &str = "github.com"; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GhPrResponse { @@ -72,6 +74,65 @@ impl Repo { } parse_gh_pr_json(&Self::stdout_text(&output)) } + + /// Existing PR URL for `bookmark` if one exists, else a GitHub compose URL. None for non-github.com remotes. + pub fn gh_pr_open_url(&self, bookmark: &str) -> Option { + if bookmark.is_empty() { + return None; + } + if let Some(pr) = self.gh_pr_info(bookmark) { + return Some(pr.url); + } + let remote = self.git_remote_url().ok()?; + let slug = github_slug(&remote)?; + let encoded = encode_path_segment(bookmark); + Some(format!("https://{GITHUB_HOST}/{slug}/pull/new/{encoded}")) + } +} + +/// Extract `owner/repo` from a github.com remote URL (scp-ssh, ssh://, or http(s)://). Rejects other hosts. +fn github_slug(raw: &str) -> Option { + let trimmed = raw.trim(); + let (host, path) = if let Some(rest) = trimmed + .strip_prefix("https://") + .or_else(|| trimmed.strip_prefix("http://")) + { + rest.split_once('/')? + } else if let Some(rest) = trimmed.strip_prefix("ssh://") { + rest.split_once('/')? + } else if let Some((_, rest)) = trimmed.split_once('@') { + rest.split_once(':')? + } else { + return None; + }; + // Strip optional userinfo (TOKEN@host or user:pass@host) and port. + let host = host.rsplit_once('@').map(|(_, h)| h).unwrap_or(host); + let host = host.split(':').next().unwrap_or(host); + if host != GITHUB_HOST { + return None; + } + let mut parts = path.trim_matches('/').split('/').filter(|s| !s.is_empty()); + let owner = parts.next()?; + let repo = parts.next()?; + let repo = repo.strip_suffix(".git").unwrap_or(repo); + if owner.is_empty() || repo.is_empty() { + return None; + } + Some(format!("{owner}/{repo}")) +} + +/// Percent-encode bytes that aren't safe in a URL path segment. Preserves `/` so `feat/foo` round-trips. +fn encode_path_segment(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out } fn parse_gh_pr_json(json: &str) -> Option { @@ -125,6 +186,32 @@ mod tests { assert_eq!(pr.checks, ChecksStatus::Failing); } + #[test] + fn slug_extracts_from_common_remote_forms() { + let expected = Some("hewigovens/jayjay"); + assert_eq!(github_slug("git@github.com:hewigovens/jayjay.git").as_deref(), expected); + assert_eq!(github_slug("https://github.com/hewigovens/jayjay.git\n").as_deref(), expected); + assert_eq!(github_slug("ssh://git@github.com/hewigovens/jayjay.git").as_deref(), expected); + // HTTPS with token/userinfo (e.g., from `gh auth setup-git`). + assert_eq!(github_slug("https://TOKEN@github.com/hewigovens/jayjay.git").as_deref(), expected); + } + + #[test] + fn encode_path_segment_escapes_specials_and_preserves_slash() { + assert_eq!(encode_path_segment("feat/foo"), "feat/foo"); + assert_eq!(encode_path_segment("weird#name?yes"), "weird%23name%3Fyes"); + } + + #[test] + fn slug_rejects_non_github_and_malformed() { + // Lookalike hosts must not pass — opening a bogus PR URL is the failure mode here. + assert_eq!(github_slug("https://github.com.evil.org/hewigovens/jayjay"), None); + assert_eq!(github_slug("https://evilgithub.com/foo/bar"), None); + assert_eq!(github_slug("https://gitlab.com/hewigovens/jayjay.git"), None); + assert_eq!(github_slug("https://github.com/lonely"), None); + assert_eq!(github_slug(""), None); + } + #[test] fn parse_pr_with_pending_checks() { let json = r#"{ diff --git a/crates/jayjay-uniffi/src/repo.rs b/crates/jayjay-uniffi/src/repo.rs index a86627f..1117475 100644 --- a/crates/jayjay-uniffi/src/repo.rs +++ b/crates/jayjay-uniffi/src/repo.rs @@ -141,6 +141,10 @@ impl JayJayRepo { self.inner.gh_pr_info(&bookmark) } + pub fn gh_pr_open_url(&self, bookmark: String) -> Option { + self.inner.gh_pr_open_url(&bookmark) + } + pub fn diff_stats(&self, rev: String) -> Result { Ok(self.inner.diff_stats(&rev)?) } diff --git a/shell/mac/Sources/JayJay/Repo/BookmarkManagerView.swift b/shell/mac/Sources/JayJay/Repo/BookmarkManagerView.swift index 3cf5376..ef48480 100644 --- a/shell/mac/Sources/JayJay/Repo/BookmarkManagerView.swift +++ b/shell/mac/Sources/JayJay/Repo/BookmarkManagerView.swift @@ -125,7 +125,8 @@ struct BookmarkManagerView: View { onResolve: { try? repo?.moveBookmark(name: bookmark.name, toRev: "@-") actions?.gitFetch() // refresh - } + }, + onOpenPR: { actions?.openPR(bookmark: bookmark.name) } ) } } @@ -142,6 +143,11 @@ private struct BookmarkManagerRow: View { let onForget: () -> Void let onPush: () -> Void let onResolve: () -> Void + let onOpenPR: () -> Void + + private var canOpenPR: Bool { + bookmark.isTrackingRemote && !bookmark.isDeleted && !isTrunkBookmark(bookmark.name) + } var body: some View { HStack(spacing: 10) { @@ -199,6 +205,11 @@ private struct BookmarkManagerRow: View { Label("Push", systemImage: "arrow.up.circle") } } + if canOpenPR { + Button { onOpenPR() } label: { + Label("Pull Request on GitHub", systemImage: "arrow.up.right.square") + } + } Divider() if bookmark.isDeleted { Button { onForget() } label: { diff --git a/shell/mac/Sources/JayJay/Repo/DAGNodeStyle.swift b/shell/mac/Sources/JayJay/Repo/DAGNodeStyle.swift index e8d8cdf..0cca420 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGNodeStyle.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGNodeStyle.swift @@ -56,11 +56,3 @@ struct DAGNodeStyle { return DAGNodeStyle(shape: shape, radius: radius, fill: fill) } } - -private let trunkBookmarkNames: Set = ["main", "master", "trunk"] - -/// Matches bare "main" as well as remote-qualified forms like "main@origin". -private func isTrunkBookmark(_ name: String) -> Bool { - let bare = name.split(separator: "@").first.map(String.init) ?? name - return trunkBookmarkNames.contains(bare) -} diff --git a/shell/mac/Sources/JayJay/Repo/DAGRow.swift b/shell/mac/Sources/JayJay/Repo/DAGRow.swift index a7207ce..fd03a1b 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGRow.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGRow.swift @@ -6,6 +6,7 @@ struct DAGRow: View { let viewModel: DAGRowViewModel var onMoveBookmarkForward: ((String) -> Void)? var onPushBookmark: ((String) -> Void)? + var onOpenPRForBookmark: ((String) -> Void)? private var change: ChangeInfo { viewModel.change @@ -212,6 +213,11 @@ struct DAGRow: View { Button("Push") { onPushBookmark?(name) } + if !isTrunkBookmark(name) { + Button("Pull Request on GitHub") { + onOpenPRForBookmark?(name) + } + } Divider() Button("Copy Bookmark Name") { NSPasteboard.general.clearContents() diff --git a/shell/mac/Sources/JayJay/Repo/DAGView.swift b/shell/mac/Sources/JayJay/Repo/DAGView.swift index 55af90e..5c9268a 100644 --- a/shell/mac/Sources/JayJay/Repo/DAGView.swift +++ b/shell/mac/Sources/JayJay/Repo/DAGView.swift @@ -12,6 +12,7 @@ struct DAGView: View { var revealRequest: DAGRevealRequest? var onMoveBookmarkForward: ((String) -> Void)? var onPushBookmark: ((String) -> Void)? + var onOpenPRForBookmark: ((String) -> Void)? var onAbandon: ((String) -> Void)? var onCreateBookmark: ((String) -> Void)? var onLoadMore: (() -> Void)? @@ -36,6 +37,7 @@ struct DAGView: View { revealRequest: DAGRevealRequest? = nil, onMoveBookmarkForward: ((String) -> Void)? = nil, onPushBookmark: ((String) -> Void)? = nil, + onOpenPRForBookmark: ((String) -> Void)? = nil, onAbandon: ((String) -> Void)? = nil, onCreateBookmark: ((String) -> Void)? = nil, onLoadMore: (() -> Void)? = nil @@ -49,6 +51,7 @@ struct DAGView: View { self.revealRequest = revealRequest self.onMoveBookmarkForward = onMoveBookmarkForward self.onPushBookmark = onPushBookmark + self.onOpenPRForBookmark = onOpenPRForBookmark self.onAbandon = onAbandon self.onCreateBookmark = onCreateBookmark self.onLoadMore = onLoadMore @@ -86,7 +89,8 @@ struct DAGView: View { previewText: rebasePreviewText(for: entry.change) ), onMoveBookmarkForward: onMoveBookmarkForward, - onPushBookmark: onPushBookmark + onPushBookmark: onPushBookmark, + onOpenPRForBookmark: onOpenPRForBookmark ) .background( GeometryReader { geo in diff --git a/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift b/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift index 44ae3cb..1ba618d 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoSidebar.swift @@ -47,6 +47,7 @@ extension RepoContentView { revealRequest: dagRevealRequest, onMoveBookmarkForward: { viewModel.moveBookmarkForward(name: $0) }, onPushBookmark: { viewModel.gitPush(bookmark: $0) }, + onOpenPRForBookmark: { viewModel.openPR(bookmark: $0) }, onAbandon: { requestAbandon($0) }, onCreateBookmark: { rev in presentBookmarkCreate(rev: rev) }, onLoadMore: viewModel.canLoadMore ? { viewModel.loadMore() } : nil diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+GitActions.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+GitActions.swift index d6542e3..2b8eb88 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+GitActions.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+GitActions.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import JayJayCore @@ -37,6 +38,21 @@ extension RepoViewModel { } } + func openPR(bookmark: String) { + guard !bookmark.isEmpty else { return } + Task.detached { [repo] in + let url = repo.ghPrOpenUrl(bookmark: bookmark).flatMap(URL.init(string:)) + await MainActor.run { [weak self] in + guard let self else { return } + if let url { + NSWorkspace.shared.open(url) + } else { + self.info = "Couldn't determine a GitHub URL — push the bookmark to a github.com remote first." + } + } + } + } + private func handleFetchResult(_ result: FetchResult) { var msg = result.message if !result.abandonedBookmarks.isEmpty { diff --git a/shell/mac/Sources/JayJay/Shared/ChangeActions.swift b/shell/mac/Sources/JayJay/Shared/ChangeActions.swift index e760892..d9bf437 100644 --- a/shell/mac/Sources/JayJay/Shared/ChangeActions.swift +++ b/shell/mac/Sources/JayJay/Shared/ChangeActions.swift @@ -1,6 +1,14 @@ import Foundation import JayJayCore +let trunkBookmarkNames: Set = ["main", "master", "trunk"] + +/// Matches bare "main" as well as remote-qualified forms like "main@origin". +func isTrunkBookmark(_ name: String) -> Bool { + let bare = name.split(separator: "@").first.map(String.init) ?? name + return trunkBookmarkNames.contains(bare) +} + protocol ChangeActions: AnyObject { func select(changeId: String?) func describeChange(rev: String, message: String) @@ -46,4 +54,5 @@ protocol BookmarkActions: AnyObject { func gitPush(bookmark: String) func gitFetch() func gitPullBookmark(name: String) + func openPR(bookmark: String) }