Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions crates/jayjay-core/src/repo/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> {
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<String> {
let trimmed = raw.trim();
let (host, path) = if let Some(rest) = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))
{
rest.split_once('/')?
Comment thread
hewigovens marked this conversation as resolved.
} 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<PrInfo> {
Expand Down Expand Up @@ -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#"{
Expand Down
4 changes: 4 additions & 0 deletions crates/jayjay-uniffi/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ impl JayJayRepo {
self.inner.gh_pr_info(&bookmark)
}

pub fn gh_pr_open_url(&self, bookmark: String) -> Option<String> {
self.inner.gh_pr_open_url(&bookmark)
}

pub fn diff_stats(&self, rev: String) -> Result<DiffStats, JayJayError> {
Ok(self.inner.diff_stats(&rev)?)
}
Expand Down
13 changes: 12 additions & 1 deletion shell/mac/Sources/JayJay/Repo/BookmarkManagerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ struct BookmarkManagerView: View {
onResolve: {
try? repo?.moveBookmark(name: bookmark.name, toRev: "@-")
actions?.gitFetch() // refresh
}
},
onOpenPR: { actions?.openPR(bookmark: bookmark.name) }
)
}
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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: {
Expand Down
8 changes: 0 additions & 8 deletions shell/mac/Sources/JayJay/Repo/DAGNodeStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,3 @@ struct DAGNodeStyle {
return DAGNodeStyle(shape: shape, radius: radius, fill: fill)
}
}

private let trunkBookmarkNames: Set<String> = ["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)
}
6 changes: 6 additions & 0 deletions shell/mac/Sources/JayJay/Repo/DAGRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 5 additions & 1 deletion shell/mac/Sources/JayJay/Repo/DAGView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -86,7 +89,8 @@ struct DAGView: View {
previewText: rebasePreviewText(for: entry.change)
),
onMoveBookmarkForward: onMoveBookmarkForward,
onPushBookmark: onPushBookmark
onPushBookmark: onPushBookmark,
onOpenPRForBookmark: onOpenPRForBookmark
)
.background(
GeometryReader { geo in
Expand Down
1 change: 1 addition & 0 deletions shell/mac/Sources/JayJay/Repo/RepoSidebar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Foundation
import JayJayCore

Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions shell/mac/Sources/JayJay/Shared/ChangeActions.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import Foundation
import JayJayCore

let trunkBookmarkNames: Set<String> = ["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)
Expand Down Expand Up @@ -46,4 +54,5 @@ protocol BookmarkActions: AnyObject {
func gitPush(bookmark: String)
func gitFetch()
func gitPullBookmark(name: String)
func openPR(bookmark: String)
}
Loading