1use std::str::FromStr;
2use std::sync::{Arc, LazyLock};
3
4use anyhow::{Context as _, Result, bail};
5use async_trait::async_trait;
6use futures::AsyncReadExt;
7use gpui::SharedString;
8use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
9use regex::Regex;
10use serde::Deserialize;
11use url::Url;
12use urlencoding::encode;
13
14use git::{
15 BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
16 PullRequest, RemoteUrl,
17};
18
19fn merge_request_number_regex() -> &'static Regex {
20 static MERGE_REQUEST_NUMBER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
21 // Matches GitLab MR references:
22 // - "(!123)" at the end of line (squash merge pattern)
23 // - "See merge request group/project!123" (standard merge commit)
24 Regex::new(r"(?:\(!(\d+)\)$|See merge request [^\s]+!(\d+))").unwrap()
25 });
26 &MERGE_REQUEST_NUMBER_REGEX
27}
28
29use crate::get_host_from_git_remote_url;
30
31#[derive(Debug, Deserialize)]
32struct CommitDetails {
33 author_email: String,
34}
35
36#[derive(Debug, Deserialize)]
37struct AvatarInfo {
38 avatar_url: String,
39}
40
41#[derive(Debug)]
42pub struct Gitlab {
43 name: String,
44 base_url: Url,
45}
46
47impl Gitlab {
48 pub fn new(name: impl Into<String>, base_url: Url) -> Self {
49 Self {
50 name: name.into(),
51 base_url,
52 }
53 }
54
55 pub fn public_instance() -> Self {
56 Self::new("GitLab", Url::parse("https://gitlab.com").unwrap())
57 }
58
59 pub fn from_remote_url(remote_url: &str) -> Result<Self> {
60 let host = get_host_from_git_remote_url(remote_url)?;
61 if host == "gitlab.com" {
62 bail!("the GitLab instance is not self-hosted");
63 }
64
65 // TODO: detecting self hosted instances by checking whether "gitlab" is in the url or not
66 // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
67 // information.
68 if !host.contains("gitlab") {
69 bail!("not a GitLab URL");
70 }
71
72 Ok(Self::new(
73 "GitLab Self-Hosted",
74 Url::parse(&format!("https://{}", host))?,
75 ))
76 }
77
78 async fn fetch_gitlab_commit_author(
79 &self,
80 repo_owner: &str,
81 repo: &str,
82 commit: &str,
83 client: &Arc<dyn HttpClient>,
84 ) -> Result<Option<AvatarInfo>> {
85 let Some(host) = self.base_url.host_str() else {
86 bail!("failed to get host from gitlab base url");
87 };
88 let project_path = format!("{}/{}", repo_owner, repo);
89 let project_path_encoded = urlencoding::encode(&project_path);
90 let url = format!(
91 "https://{host}/api/v4/projects/{project_path_encoded}/repository/commits/{commit}"
92 );
93
94 let request = Request::get(&url)
95 .header("Content-Type", "application/json")
96 .follow_redirects(http_client::RedirectPolicy::FollowAll);
97
98 let mut response = client
99 .send(request.body(AsyncBody::default())?)
100 .await
101 .with_context(|| format!("error fetching GitLab commit details at {:?}", url))?;
102
103 let mut body = Vec::new();
104 response.body_mut().read_to_end(&mut body).await?;
105
106 if response.status().is_client_error() {
107 let text = String::from_utf8_lossy(body.as_slice());
108 bail!(
109 "status error {}, response: {text:?}",
110 response.status().as_u16()
111 );
112 }
113
114 let body_str = std::str::from_utf8(&body)?;
115
116 let author_email = serde_json::from_str::<CommitDetails>(body_str)
117 .map(|commit| commit.author_email)
118 .context("failed to deserialize GitLab commit details")?;
119
120 let avatar_info_url = format!("https://{host}/api/v4/avatar?email={author_email}");
121
122 let request = Request::get(&avatar_info_url)
123 .header("Content-Type", "application/json")
124 .follow_redirects(http_client::RedirectPolicy::FollowAll);
125
126 let mut response = client
127 .send(request.body(AsyncBody::default())?)
128 .await
129 .with_context(|| format!("error fetching GitLab avatar info at {:?}", url))?;
130
131 let mut body = Vec::new();
132 response.body_mut().read_to_end(&mut body).await?;
133
134 if response.status().is_client_error() {
135 let text = String::from_utf8_lossy(body.as_slice());
136 bail!(
137 "status error {}, response: {text:?}",
138 response.status().as_u16()
139 );
140 }
141
142 let body_str = std::str::from_utf8(&body)?;
143
144 serde_json::from_str::<Option<AvatarInfo>>(body_str)
145 .context("failed to deserialize GitLab avatar info")
146 }
147}
148
149#[async_trait]
150impl GitHostingProvider for Gitlab {
151 fn name(&self) -> String {
152 self.name.clone()
153 }
154
155 fn base_url(&self) -> Url {
156 self.base_url.clone()
157 }
158
159 fn supports_avatars(&self) -> bool {
160 true
161 }
162
163 fn format_line_number(&self, line: u32) -> String {
164 format!("L{line}")
165 }
166
167 fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
168 format!("L{start_line}-{end_line}")
169 }
170
171 fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
172 let url = RemoteUrl::from_str(url).ok()?;
173
174 let host = url.host_str()?;
175 if host != self.base_url.host_str()? {
176 return None;
177 }
178
179 let mut path_segments = url.path_segments()?.collect::<Vec<_>>();
180 let repo = path_segments.pop()?.trim_end_matches(".git");
181 let owner = path_segments.join("/");
182
183 Some(ParsedGitRemote {
184 owner: owner.into(),
185 repo: repo.into(),
186 })
187 }
188
189 fn build_commit_permalink(
190 &self,
191 remote: &ParsedGitRemote,
192 params: BuildCommitPermalinkParams,
193 ) -> Url {
194 let BuildCommitPermalinkParams { sha } = params;
195 let ParsedGitRemote { owner, repo } = remote;
196
197 self.base_url()
198 .join(&format!("{owner}/{repo}/-/commit/{sha}"))
199 .unwrap()
200 }
201
202 fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
203 let ParsedGitRemote { owner, repo } = remote;
204 let BuildPermalinkParams {
205 sha,
206 path,
207 selection,
208 } = params;
209
210 let mut permalink = self
211 .base_url()
212 .join(&format!("{owner}/{repo}/-/blob/{sha}/{path}"))
213 .unwrap();
214 if path.ends_with(".md") {
215 permalink.set_query(Some("plain=1"));
216 }
217 permalink.set_fragment(
218 selection
219 .map(|selection| self.line_fragment(&selection))
220 .as_deref(),
221 );
222 permalink
223 }
224
225 fn build_create_pull_request_url(
226 &self,
227 remote: &ParsedGitRemote,
228 source_branch: &str,
229 ) -> Option<Url> {
230 let mut url = self
231 .base_url()
232 .join(&format!(
233 "{}/{}/-/merge_requests/new",
234 remote.owner, remote.repo
235 ))
236 .ok()?;
237
238 let query = format!("merge_request%5Bsource_branch%5D={}", encode(source_branch));
239
240 url.set_query(Some(&query));
241 Some(url)
242 }
243
244 fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
245 // Check commit message for GitLab MR references
246 let capture = merge_request_number_regex().captures(message)?;
247 // The regex has two capture groups - one for "(!123)" pattern, one for "See merge request" pattern
248 let number = capture
249 .get(1)
250 .or_else(|| capture.get(2))?
251 .as_str()
252 .parse::<u32>()
253 .ok()?;
254
255 let mut url = self.base_url();
256 let path = format!(
257 "{}/{}/-/merge_requests/{}",
258 remote.owner, remote.repo, number
259 );
260 url.set_path(&path);
261
262 Some(PullRequest { number, url })
263 }
264
265 async fn commit_author_avatar_url(
266 &self,
267 repo_owner: &str,
268 repo: &str,
269 commit: SharedString,
270 _author_email: Option<SharedString>,
271 http_client: Arc<dyn HttpClient>,
272 ) -> Result<Option<Url>> {
273 let commit = commit.to_string();
274 let avatar_url = self
275 .fetch_gitlab_commit_author(repo_owner, repo, &commit, &http_client)
276 .await?
277 .map(|author| -> Result<Url, url::ParseError> {
278 let mut url = Url::parse(&author.avatar_url)?;
279 if let Some(host) = url.host_str() {
280 let size_query = if host.contains("gravatar") || host.contains("libravatar") {
281 Some("s=128")
282 } else if self
283 .base_url
284 .host_str()
285 .is_some_and(|base_host| host.contains(base_host))
286 {
287 Some("width=128")
288 } else {
289 None
290 };
291 url.set_query(size_query);
292 }
293 Ok(url)
294 })
295 .transpose()?;
296 Ok(avatar_url)
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use git::repository::repo_path;
303 use pretty_assertions::assert_eq;
304
305 use super::*;
306
307 #[test]
308 fn test_invalid_self_hosted_remote_url() {
309 let remote_url = "https://gitlab.com/zed-industries/zed.git";
310 let gitlab = Gitlab::from_remote_url(remote_url);
311 assert!(gitlab.is_err());
312 }
313
314 #[test]
315 fn test_parse_remote_url_given_ssh_url() {
316 let parsed_remote = Gitlab::public_instance()
317 .parse_remote_url("git@gitlab.com:zed-industries/zed.git")
318 .unwrap();
319
320 assert_eq!(
321 parsed_remote,
322 ParsedGitRemote {
323 owner: "zed-industries".into(),
324 repo: "zed".into(),
325 }
326 );
327 }
328
329 #[test]
330 fn test_parse_remote_url_given_https_url() {
331 let parsed_remote = Gitlab::public_instance()
332 .parse_remote_url("https://gitlab.com/zed-industries/zed.git")
333 .unwrap();
334
335 assert_eq!(
336 parsed_remote,
337 ParsedGitRemote {
338 owner: "zed-industries".into(),
339 repo: "zed".into(),
340 }
341 );
342 }
343
344 #[test]
345 fn test_parse_remote_url_given_self_hosted_ssh_url() {
346 let remote_url = "git@gitlab.my-enterprise.com:zed-industries/zed.git";
347
348 let parsed_remote = Gitlab::from_remote_url(remote_url)
349 .unwrap()
350 .parse_remote_url(remote_url)
351 .unwrap();
352
353 assert_eq!(
354 parsed_remote,
355 ParsedGitRemote {
356 owner: "zed-industries".into(),
357 repo: "zed".into(),
358 }
359 );
360 }
361
362 #[test]
363 fn test_parse_remote_url_given_self_hosted_https_url_with_subgroup() {
364 let remote_url = "https://gitlab.my-enterprise.com/group/subgroup/zed.git";
365 let parsed_remote = Gitlab::from_remote_url(remote_url)
366 .unwrap()
367 .parse_remote_url(remote_url)
368 .unwrap();
369
370 assert_eq!(
371 parsed_remote,
372 ParsedGitRemote {
373 owner: "group/subgroup".into(),
374 repo: "zed".into(),
375 }
376 );
377 }
378
379 #[test]
380 fn test_build_gitlab_permalink() {
381 let permalink = Gitlab::public_instance().build_permalink(
382 ParsedGitRemote {
383 owner: "zed-industries".into(),
384 repo: "zed".into(),
385 },
386 BuildPermalinkParams::new(
387 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
388 &repo_path("crates/editor/src/git/permalink.rs"),
389 None,
390 ),
391 );
392
393 let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
394 assert_eq!(permalink.to_string(), expected_url.to_string())
395 }
396
397 #[test]
398 fn test_build_gitlab_permalink_with_single_line_selection() {
399 let permalink = Gitlab::public_instance().build_permalink(
400 ParsedGitRemote {
401 owner: "zed-industries".into(),
402 repo: "zed".into(),
403 },
404 BuildPermalinkParams::new(
405 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
406 &repo_path("crates/editor/src/git/permalink.rs"),
407 Some(6..6),
408 ),
409 );
410
411 let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
412 assert_eq!(permalink.to_string(), expected_url.to_string())
413 }
414
415 #[test]
416 fn test_build_gitlab_permalink_with_multi_line_selection() {
417 let permalink = Gitlab::public_instance().build_permalink(
418 ParsedGitRemote {
419 owner: "zed-industries".into(),
420 repo: "zed".into(),
421 },
422 BuildPermalinkParams::new(
423 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
424 &repo_path("crates/editor/src/git/permalink.rs"),
425 Some(23..47),
426 ),
427 );
428
429 let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-48";
430 assert_eq!(permalink.to_string(), expected_url.to_string())
431 }
432
433 #[test]
434 fn test_build_gitlab_create_pr_url() {
435 let remote = ParsedGitRemote {
436 owner: "zed-industries".into(),
437 repo: "zed".into(),
438 };
439
440 let provider = Gitlab::public_instance();
441
442 let url = provider
443 .build_create_pull_request_url(&remote, "feature/cool stuff")
444 .expect("create PR url should be constructed");
445
446 assert_eq!(
447 url.as_str(),
448 "https://gitlab.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fcool%20stuff"
449 );
450 }
451
452 #[test]
453 fn test_build_gitlab_self_hosted_permalink_from_ssh_url() {
454 let gitlab =
455 Gitlab::from_remote_url("git@gitlab.some-enterprise.com:zed-industries/zed.git")
456 .unwrap();
457 let permalink = gitlab.build_permalink(
458 ParsedGitRemote {
459 owner: "zed-industries".into(),
460 repo: "zed".into(),
461 },
462 BuildPermalinkParams::new(
463 "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
464 &repo_path("crates/editor/src/git/permalink.rs"),
465 None,
466 ),
467 );
468
469 let expected_url = "https://gitlab.some-enterprise.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
470 assert_eq!(permalink.to_string(), expected_url.to_string())
471 }
472
473 #[test]
474 fn test_build_gitlab_self_hosted_permalink_from_https_url() {
475 let gitlab =
476 Gitlab::from_remote_url("https://gitlab-instance.big-co.com/zed-industries/zed.git")
477 .unwrap();
478 let permalink = gitlab.build_permalink(
479 ParsedGitRemote {
480 owner: "zed-industries".into(),
481 repo: "zed".into(),
482 },
483 BuildPermalinkParams::new(
484 "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
485 &repo_path("crates/zed/src/main.rs"),
486 None,
487 ),
488 );
489
490 let expected_url = "https://gitlab-instance.big-co.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
491 assert_eq!(permalink.to_string(), expected_url.to_string())
492 }
493
494 #[test]
495 fn test_build_create_pull_request_url() {
496 let remote = ParsedGitRemote {
497 owner: "zed-industries".into(),
498 repo: "zed".into(),
499 };
500
501 let github = Gitlab::public_instance();
502 let url = github
503 .build_create_pull_request_url(&remote, "feature/new-feature")
504 .unwrap();
505
506 assert_eq!(
507 url.as_str(),
508 "https://gitlab.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fnew-feature"
509 );
510
511 let base_url = Url::parse("https://gitlab.zed.com").unwrap();
512 let github = Gitlab::new("GitLab Self-Hosted", base_url);
513 let url = github
514 .build_create_pull_request_url(&remote, "feature/new-feature")
515 .expect("should be able to build pull request url");
516
517 assert_eq!(
518 url.as_str(),
519 "https://gitlab.zed.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fnew-feature"
520 );
521 }
522
523 #[test]
524 fn test_extract_merge_request_from_squash_commit() {
525 let remote = ParsedGitRemote {
526 owner: "zed-industries".into(),
527 repo: "zed".into(),
528 };
529
530 let provider = Gitlab::public_instance();
531
532 // Test squash merge pattern: "commit message (!123)"
533 let message = "Add new feature (!456)";
534 let pull_request = provider.extract_pull_request(&remote, message).unwrap();
535
536 assert_eq!(pull_request.number, 456);
537 assert_eq!(
538 pull_request.url.as_str(),
539 "https://gitlab.com/zed-industries/zed/-/merge_requests/456"
540 );
541 }
542
543 #[test]
544 fn test_extract_merge_request_from_merge_commit() {
545 let remote = ParsedGitRemote {
546 owner: "zed-industries".into(),
547 repo: "zed".into(),
548 };
549
550 let provider = Gitlab::public_instance();
551
552 // Test standard merge commit pattern: "See merge request group/project!123"
553 let message =
554 "Merge branch 'feature' into 'main'\n\nSee merge request zed-industries/zed!789";
555 let pull_request = provider.extract_pull_request(&remote, message).unwrap();
556
557 assert_eq!(pull_request.number, 789);
558 assert_eq!(
559 pull_request.url.as_str(),
560 "https://gitlab.com/zed-industries/zed/-/merge_requests/789"
561 );
562 }
563
564 #[test]
565 fn test_extract_merge_request_self_hosted() {
566 let base_url = Url::parse("https://gitlab.my-company.com").unwrap();
567 let provider = Gitlab::new("GitLab Self-Hosted", base_url);
568
569 let remote = ParsedGitRemote {
570 owner: "team".into(),
571 repo: "project".into(),
572 };
573
574 let message = "Fix bug (!42)";
575 let pull_request = provider.extract_pull_request(&remote, message).unwrap();
576
577 assert_eq!(pull_request.number, 42);
578 assert_eq!(
579 pull_request.url.as_str(),
580 "https://gitlab.my-company.com/team/project/-/merge_requests/42"
581 );
582 }
583
584 #[test]
585 fn test_extract_merge_request_no_match() {
586 let remote = ParsedGitRemote {
587 owner: "zed-industries".into(),
588 repo: "zed".into(),
589 };
590
591 let provider = Gitlab::public_instance();
592
593 // No MR reference in message
594 let message = "Just a regular commit message";
595 let pull_request = provider.extract_pull_request(&remote, message);
596
597 assert!(pull_request.is_none());
598 }
599}