| @@ -0,0 +1,302 @@ |
| | 1 | use std::str::FromStr; |
| | 2 | use std::sync::{Arc, LazyLock}; |
| | 3 | |
| | 4 | use anyhow::{bail, Context, Result}; |
| | 5 | use async_trait::async_trait; |
| | 6 | use futures::AsyncReadExt; |
| | 7 | use git::{ |
| | 8 | BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote, |
| | 9 | PullRequest, RemoteUrl, |
| | 10 | }; |
| | 11 | use gpui::SharedString; |
| | 12 | use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; |
| | 13 | use regex::Regex; |
| | 14 | use serde::Deserialize; |
| | 15 | use url::Url; |
| | 16 | |
| | 17 | const CHROMIUM_REVIEW_URL: &str = "https://chromium-review.googlesource.com"; |
| | 18 | |
| | 19 | /// Parses Gerrit URLs like |
| | 20 | /// https://chromium-review.googlesource.com/c/chromium/src/+/3310961. |
| | 21 | fn pull_request_regex() -> &'static Regex { |
| | 22 | static PULL_REQUEST_NUMBER_REGEX: LazyLock<Regex> = LazyLock::new(|| { |
| | 23 | Regex::new(&format!( |
| | 24 | r#"Reviewed-on: ({CHROMIUM_REVIEW_URL}/c/(.*)/\+/(\d+))"# |
| | 25 | )) |
| | 26 | .unwrap() |
| | 27 | }); |
| | 28 | &PULL_REQUEST_NUMBER_REGEX |
| | 29 | } |
| | 30 | |
| | 31 | /// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html |
| | 32 | #[derive(Debug, Deserialize)] |
| | 33 | struct ChangeInfo { |
| | 34 | owner: AccountInfo, |
| | 35 | } |
| | 36 | |
| | 37 | #[derive(Debug, Deserialize)] |
| | 38 | pub struct AccountInfo { |
| | 39 | #[serde(rename = "_account_id")] |
| | 40 | id: u64, |
| | 41 | } |
| | 42 | |
| | 43 | pub struct Chromium; |
| | 44 | |
| | 45 | impl Chromium { |
| | 46 | async fn fetch_chromium_commit_author( |
| | 47 | &self, |
| | 48 | _repo: &str, |
| | 49 | commit: &str, |
| | 50 | client: &Arc<dyn HttpClient>, |
| | 51 | ) -> Result<Option<AccountInfo>> { |
| | 52 | let url = format!("{CHROMIUM_REVIEW_URL}/changes/{commit}"); |
| | 53 | |
| | 54 | let request = Request::get(&url) |
| | 55 | .header("Content-Type", "application/json") |
| | 56 | .follow_redirects(http_client::RedirectPolicy::FollowAll); |
| | 57 | |
| | 58 | let mut response = client |
| | 59 | .send(request.body(AsyncBody::default())?) |
| | 60 | .await |
| | 61 | .with_context(|| format!("error fetching Gerrit commit details at {:?}", url))?; |
| | 62 | |
| | 63 | let mut body = Vec::new(); |
| | 64 | response.body_mut().read_to_end(&mut body).await?; |
| | 65 | |
| | 66 | if response.status().is_client_error() { |
| | 67 | let text = String::from_utf8_lossy(body.as_slice()); |
| | 68 | bail!( |
| | 69 | "status error {}, response: {text:?}", |
| | 70 | response.status().as_u16() |
| | 71 | ); |
| | 72 | } |
| | 73 | |
| | 74 | // Remove XSSI protection prefix. |
| | 75 | let body_str = std::str::from_utf8(&body)?.trim_start_matches(")]}'"); |
| | 76 | |
| | 77 | serde_json::from_str::<ChangeInfo>(body_str) |
| | 78 | .map(|change| Some(change.owner)) |
| | 79 | .context("failed to deserialize Gerrit change info") |
| | 80 | } |
| | 81 | } |
| | 82 | |
| | 83 | #[async_trait] |
| | 84 | impl GitHostingProvider for Chromium { |
| | 85 | fn name(&self) -> String { |
| | 86 | "Chromium".to_string() |
| | 87 | } |
| | 88 | |
| | 89 | fn base_url(&self) -> Url { |
| | 90 | Url::parse("https://chromium.googlesource.com").unwrap() |
| | 91 | } |
| | 92 | |
| | 93 | fn supports_avatars(&self) -> bool { |
| | 94 | true |
| | 95 | } |
| | 96 | |
| | 97 | fn format_line_number(&self, line: u32) -> String { |
| | 98 | format!("{line}") |
| | 99 | } |
| | 100 | |
| | 101 | fn format_line_numbers(&self, start_line: u32, _end_line: u32) -> String { |
| | 102 | format!("{start_line}") |
| | 103 | } |
| | 104 | |
| | 105 | fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> { |
| | 106 | let url = RemoteUrl::from_str(url).ok()?; |
| | 107 | |
| | 108 | let host = url.host_str()?; |
| | 109 | if host != self.base_url().host_str()? { |
| | 110 | return None; |
| | 111 | } |
| | 112 | |
| | 113 | let path_segments = url.path_segments()?.collect::<Vec<_>>(); |
| | 114 | let joined_path = path_segments.join("/"); |
| | 115 | let repo = joined_path.trim_end_matches(".git"); |
| | 116 | |
| | 117 | Some(ParsedGitRemote { |
| | 118 | owner: Arc::from(""), |
| | 119 | repo: repo.into(), |
| | 120 | }) |
| | 121 | } |
| | 122 | |
| | 123 | fn build_commit_permalink( |
| | 124 | &self, |
| | 125 | remote: &ParsedGitRemote, |
| | 126 | params: BuildCommitPermalinkParams, |
| | 127 | ) -> Url { |
| | 128 | let BuildCommitPermalinkParams { sha } = params; |
| | 129 | let ParsedGitRemote { owner: _, repo } = remote; |
| | 130 | |
| | 131 | self.base_url().join(&format!("{repo}/+/{sha}")).unwrap() |
| | 132 | } |
| | 133 | |
| | 134 | fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url { |
| | 135 | let ParsedGitRemote { owner: _, repo } = remote; |
| | 136 | let BuildPermalinkParams { |
| | 137 | sha, |
| | 138 | path, |
| | 139 | selection, |
| | 140 | } = params; |
| | 141 | |
| | 142 | let mut permalink = self |
| | 143 | .base_url() |
| | 144 | .join(&format!("{repo}/+/{sha}/{path}")) |
| | 145 | .unwrap(); |
| | 146 | permalink.set_fragment( |
| | 147 | selection |
| | 148 | .map(|selection| self.line_fragment(&selection)) |
| | 149 | .as_deref(), |
| | 150 | ); |
| | 151 | permalink |
| | 152 | } |
| | 153 | |
| | 154 | fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> { |
| | 155 | let capture = pull_request_regex().captures(message)?; |
| | 156 | let url = Url::parse(capture.get(1)?.as_str()).unwrap(); |
| | 157 | let repo = capture.get(2)?.as_str(); |
| | 158 | if repo != remote.repo.as_ref() { |
| | 159 | return None; |
| | 160 | } |
| | 161 | |
| | 162 | let number = capture.get(3)?.as_str().parse::<u32>().ok()?; |
| | 163 | |
| | 164 | Some(PullRequest { number, url }) |
| | 165 | } |
| | 166 | |
| | 167 | async fn commit_author_avatar_url( |
| | 168 | &self, |
| | 169 | _repo_owner: &str, |
| | 170 | repo: &str, |
| | 171 | commit: SharedString, |
| | 172 | http_client: Arc<dyn HttpClient>, |
| | 173 | ) -> Result<Option<Url>> { |
| | 174 | let commit = commit.to_string(); |
| | 175 | let Some(author) = self |
| | 176 | .fetch_chromium_commit_author(repo, &commit, &http_client) |
| | 177 | .await? |
| | 178 | else { |
| | 179 | return Ok(None); |
| | 180 | }; |
| | 181 | |
| | 182 | let mut avatar_url = Url::parse(&format!( |
| | 183 | "{CHROMIUM_REVIEW_URL}/accounts/{}/avatar", |
| | 184 | &author.id |
| | 185 | ))?; |
| | 186 | avatar_url.set_query(Some("size=128")); |
| | 187 | |
| | 188 | Ok(Some(avatar_url)) |
| | 189 | } |
| | 190 | } |
| | 191 | |
| | 192 | #[cfg(test)] |
| | 193 | mod tests { |
| | 194 | use indoc::indoc; |
| | 195 | use pretty_assertions::assert_eq; |
| | 196 | |
| | 197 | use super::*; |
| | 198 | |
| | 199 | #[test] |
| | 200 | fn test_parse_remote_url_given_https_url() { |
| | 201 | let parsed_remote = Chromium |
| | 202 | .parse_remote_url("https://chromium.googlesource.com/chromium/src") |
| | 203 | .unwrap(); |
| | 204 | |
| | 205 | assert_eq!( |
| | 206 | parsed_remote, |
| | 207 | ParsedGitRemote { |
| | 208 | owner: Arc::from(""), |
| | 209 | repo: "chromium/src".into(), |
| | 210 | } |
| | 211 | ); |
| | 212 | } |
| | 213 | |
| | 214 | #[test] |
| | 215 | fn test_build_chromium_permalink() { |
| | 216 | let permalink = Chromium.build_permalink( |
| | 217 | ParsedGitRemote { |
| | 218 | owner: Arc::from(""), |
| | 219 | repo: "chromium/src".into(), |
| | 220 | }, |
| | 221 | BuildPermalinkParams { |
| | 222 | sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", |
| | 223 | path: "ui/base/cursor/cursor.h", |
| | 224 | selection: None, |
| | 225 | }, |
| | 226 | ); |
| | 227 | |
| | 228 | let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h"; |
| | 229 | assert_eq!(permalink.to_string(), expected_url.to_string()) |
| | 230 | } |
| | 231 | |
| | 232 | #[test] |
| | 233 | fn test_build_chromium_permalink_with_single_line_selection() { |
| | 234 | let permalink = Chromium.build_permalink( |
| | 235 | ParsedGitRemote { |
| | 236 | owner: Arc::from(""), |
| | 237 | repo: "chromium/src".into(), |
| | 238 | }, |
| | 239 | BuildPermalinkParams { |
| | 240 | sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", |
| | 241 | path: "ui/base/cursor/cursor.h", |
| | 242 | selection: Some(18..18), |
| | 243 | }, |
| | 244 | ); |
| | 245 | |
| | 246 | let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19"; |
| | 247 | assert_eq!(permalink.to_string(), expected_url.to_string()) |
| | 248 | } |
| | 249 | |
| | 250 | #[test] |
| | 251 | fn test_build_chromium_permalink_with_multi_line_selection() { |
| | 252 | let permalink = Chromium.build_permalink( |
| | 253 | ParsedGitRemote { |
| | 254 | owner: Arc::from(""), |
| | 255 | repo: "chromium/src".into(), |
| | 256 | }, |
| | 257 | BuildPermalinkParams { |
| | 258 | sha: "fea5080b182fc92e3be0c01c5dece602fe70b588", |
| | 259 | path: "ui/base/cursor/cursor.h", |
| | 260 | selection: Some(18..30), |
| | 261 | }, |
| | 262 | ); |
| | 263 | |
| | 264 | let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19"; |
| | 265 | assert_eq!(permalink.to_string(), expected_url.to_string()) |
| | 266 | } |
| | 267 | |
| | 268 | #[test] |
| | 269 | fn test_chromium_pull_requests() { |
| | 270 | let remote = ParsedGitRemote { |
| | 271 | owner: Arc::from(""), |
| | 272 | repo: "chromium/src".into(), |
| | 273 | }; |
| | 274 | |
| | 275 | let message = "This does not contain a pull request"; |
| | 276 | assert!(Chromium.extract_pull_request(&remote, message).is_none()); |
| | 277 | |
| | 278 | // Pull request number at end of "Reviewed-on:" line |
| | 279 | let message = indoc! {r#" |
| | 280 | Test commit header |
| | 281 | |
| | 282 | Test commit description with multiple |
| | 283 | lines. |
| | 284 | |
| | 285 | Bug: 1193775, 1270302 |
| | 286 | Change-Id: Id15e9b4d75cce43ebd5fe34f0fb37d5e1e811b66 |
| | 287 | Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3310961 |
| | 288 | Reviewed-by: Test reviewer <test@example.com> |
| | 289 | Cr-Commit-Position: refs/heads/main@{#1054973} |
| | 290 | "# |
| | 291 | }; |
| | 292 | |
| | 293 | assert_eq!( |
| | 294 | Chromium |
| | 295 | .extract_pull_request(&remote, &message) |
| | 296 | .unwrap() |
| | 297 | .url |
| | 298 | .as_str(), |
| | 299 | "https://chromium-review.googlesource.com/c/chromium/src/+/3310961" |
| | 300 | ); |
| | 301 | } |
| | 302 | } |