Add pull requests to git blame tooltip (#10784)

Thorsten Ball created

Release Notes:

- Added links to GitHub pull requests to the git blame tooltips, if they
are available.

Screenshot:

(Yes, the icon will be resized! cc @iamnbutler)

![screenshot-2024-04-19-18 31
13@2x](https://github.com/zed-industries/zed/assets/1185253/774af0b3-f587-4acc-aa1e-1846c2bec127)

Change summary

Cargo.lock                               |  1 
assets/icons/pull_request.svg            |  1 
crates/editor/src/blame_entry_tooltip.rs | 69 +++++++++++++++------
crates/editor/src/git/blame.rs           |  7 ++
crates/git/Cargo.toml                    |  1 
crates/git/src/git.rs                    |  1 
crates/git/src/pull_request.rs           | 83 ++++++++++++++++++++++++++
crates/ui/src/components/icon.rs         |  2 
8 files changed, 145 insertions(+), 20 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4317,6 +4317,7 @@ dependencies = [
  "log",
  "parking_lot",
  "pretty_assertions",
+ "regex",
  "rope",
  "serde",
  "serde_json",

assets/icons/pull_request.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-git-pull-request-arrow"><circle cx="5" cy="6" r="3"/><path d="M5 9v12"/><circle cx="19" cy="18" r="3"/><path d="m15 9-3-3 3-3"/><path d="M12 6h5a2 2 0 0 1 2 2v7"/></svg>

crates/editor/src/blame_entry_tooltip.rs 🔗

@@ -149,6 +149,11 @@ impl Render for BlameEntryTooltip {
             })
             .unwrap_or("<no commit message>".into_any());
 
+        let pull_request = self
+            .details
+            .as_ref()
+            .and_then(|details| details.pull_request.clone());
+
         let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
         let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4);
 
@@ -192,27 +197,51 @@ impl Render for BlameEntryTooltip {
                                 .justify_between()
                                 .child(absolute_timestamp)
                                 .child(
-                                    Button::new("commit-sha-button", short_commit_id.clone())
-                                        .style(ButtonStyle::Transparent)
-                                        .color(Color::Muted)
-                                        .icon(IconName::FileGit)
-                                        .icon_color(Color::Muted)
-                                        .icon_position(IconPosition::Start)
-                                        .disabled(
-                                            self.details.as_ref().map_or(true, |details| {
-                                                details.permalink.is_none()
-                                            }),
-                                        )
-                                        .when_some(
-                                            self.details
-                                                .as_ref()
-                                                .and_then(|details| details.permalink.clone()),
-                                            |this, url| {
-                                                this.on_click(move |_, cx| {
+                                    h_flex()
+                                        .gap_2()
+                                        .when_some(pull_request, |this, pr| {
+                                            this.child(
+                                                Button::new(
+                                                    "pull-request-button",
+                                                    format!("#{}", pr.number),
+                                                )
+                                                .color(Color::Muted)
+                                                .icon(IconName::PullRequest)
+                                                .icon_color(Color::Muted)
+                                                .icon_position(IconPosition::Start)
+                                                .style(ButtonStyle::Transparent)
+                                                .on_click(move |_, cx| {
                                                     cx.stop_propagation();
-                                                    cx.open_url(url.as_str())
-                                                })
-                                            },
+                                                    cx.open_url(pr.url.as_str())
+                                                }),
+                                            )
+                                        })
+                                        .child(
+                                            Button::new(
+                                                "commit-sha-button",
+                                                short_commit_id.clone(),
+                                            )
+                                            .style(ButtonStyle::Transparent)
+                                            .color(Color::Muted)
+                                            .icon(IconName::FileGit)
+                                            .icon_color(Color::Muted)
+                                            .icon_position(IconPosition::Start)
+                                            .disabled(
+                                                self.details.as_ref().map_or(true, |details| {
+                                                    details.permalink.is_none()
+                                                }),
+                                            )
+                                            .when_some(
+                                                self.details
+                                                    .as_ref()
+                                                    .and_then(|details| details.permalink.clone()),
+                                                |this, url| {
+                                                    this.on_click(move |_, cx| {
+                                                        cx.stop_propagation();
+                                                        cx.open_url(url.as_str())
+                                                    })
+                                                },
+                                            ),
                                         ),
                                 ),
                         ),

crates/editor/src/git/blame.rs 🔗

@@ -6,6 +6,7 @@ use git::{
     blame::{Blame, BlameEntry},
     hosting_provider::HostingProvider,
     permalink::{build_commit_permalink, parse_git_remote_url},
+    pull_request::{extract_pull_request, PullRequest},
     Oid,
 };
 use gpui::{Model, ModelContext, Subscription, Task};
@@ -75,6 +76,7 @@ pub struct CommitDetails {
     pub message: String,
     pub parsed_message: ParsedMarkdown,
     pub permalink: Option<Url>,
+    pub pull_request: Option<PullRequest>,
     pub remote: Option<GitRemote>,
 }
 
@@ -438,6 +440,10 @@ async fn parse_commit_messages(
             repo: remote.repo.to_string(),
         });
 
+        let pull_request = parsed_remote_url
+            .as_ref()
+            .and_then(|remote| extract_pull_request(remote, &message));
+
         commit_details.insert(
             oid,
             CommitDetails {
@@ -445,6 +451,7 @@ async fn parse_commit_messages(
                 parsed_message,
                 permalink,
                 remote,
+                pull_request,
             },
         );
     }

crates/git/Cargo.toml 🔗

@@ -25,6 +25,7 @@ time.workspace = true
 url.workspace = true
 util.workspace = true
 serde.workspace = true
+regex.workspace = true
 rope.workspace = true
 parking_lot.workspace = true
 windows.workspace = true

crates/git/src/git.rs 🔗

@@ -12,6 +12,7 @@ pub mod commit;
 pub mod diff;
 pub mod hosting_provider;
 pub mod permalink;
+pub mod pull_request;
 pub mod repository;
 
 lazy_static! {

crates/git/src/pull_request.rs 🔗

@@ -0,0 +1,83 @@
+use lazy_static::lazy_static;
+use url::Url;
+
+use crate::{hosting_provider::HostingProvider, permalink::ParsedGitRemote};
+
+lazy_static! {
+    static ref GITHUB_PULL_REQUEST_NUMBER: regex::Regex =
+        regex::Regex::new(r"\(#(\d+)\)$").unwrap();
+}
+
+#[derive(Clone, Debug)]
+pub struct PullRequest {
+    pub number: u32,
+    pub url: Url,
+}
+
+pub fn extract_pull_request(remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
+    match remote.provider {
+        HostingProvider::Github => {
+            let line = message.lines().next()?;
+            let capture = GITHUB_PULL_REQUEST_NUMBER.captures(line)?;
+            let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
+
+            let mut url = remote.provider.base_url();
+            let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
+            url.set_path(&path);
+
+            Some(PullRequest { number, url })
+        }
+        _ => None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use unindent::Unindent;
+
+    use crate::{
+        hosting_provider::HostingProvider, permalink::ParsedGitRemote,
+        pull_request::extract_pull_request,
+    };
+
+    #[test]
+    fn test_github_pull_requests() {
+        let remote = ParsedGitRemote {
+            provider: HostingProvider::Github,
+            owner: "zed-industries",
+            repo: "zed",
+        };
+
+        let message = "This does not contain a pull request";
+        assert!(extract_pull_request(&remote, message).is_none());
+
+        // Pull request number at end of first line
+        let message = r#"
+            project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
+
+            Fixes #10597
+
+            Release Notes:
+
+            - Fixed "project panel: collapse all entries" expanding collapsed worktrees.
+            "#
+        .unindent();
+
+        assert_eq!(
+            extract_pull_request(&remote, &message)
+                .unwrap()
+                .url
+                .as_str(),
+            "https://github.com/zed-industries/zed/pull/10687"
+        );
+
+        // Pull request number in middle of line, which we want to ignore
+        let message = r#"
+            Follow-up to #10687 to fix problems
+
+            See the original PR, this is a fix.
+            "#
+        .unindent();
+        assert!(extract_pull_request(&remote, &message).is_none());
+    }
+}

crates/ui/src/components/icon.rs 🔗

@@ -121,6 +121,7 @@ pub enum IconName {
     WholeWord,
     XCircle,
     ZedXCopilot,
+    PullRequest,
 }
 
 impl IconName {
@@ -222,6 +223,7 @@ impl IconName {
             IconName::WholeWord => "icons/word_search.svg",
             IconName::XCircle => "icons/error.svg",
             IconName::ZedXCopilot => "icons/zed_x_copilot.svg",
+            IconName::PullRequest => "icons/pull_request.svg",
         }
     }
 }