1use super::common::{
3 CreatePullRequest, CreateRepository, ForkRepository, ForkedFromRepository,
4 ListPullRequestFilters, PullRequest, PullRequestState, PullRequestStateFilter,
5 PullRequestUserFilter, Repository, RepositoryVisibility, User, VersionControl,
6 VersionControlSettings,
7};
8use eyre::{eyre, Result};
9use native_tls::TlsConnector;
10use serde::{de::DeserializeOwned, Deserialize, Serialize};
11use std::{fmt::Debug, sync::Arc};
12use time::OffsetDateTime;
13use tracing::{info, instrument, trace};
14use ureq::{Agent, AgentBuilder, Error};
15use urlencoding::encode;
16
17#[derive(Debug, Deserialize, Serialize)]
18pub struct GitLabUser {
19 pub id: u32,
20 pub username: String,
21 pub name: String,
22}
23
24impl From<GitLabUser> for User {
25 fn from(user: GitLabUser) -> User {
26 let GitLabUser { id, username, .. } = user;
27 User {
28 id: id.to_string(),
29 username,
30 }
31 }
32}
33
34#[derive(Debug, Deserialize, Serialize)]
35pub struct GitLabApprovalUser {
36 user: GitLabUser,
37}
38
39#[derive(Debug, Deserialize, Serialize)]
40pub struct GitLabApproval {
41 approved: bool,
42 approved_by: Vec<GitLabApprovalUser>,
43}
44
45#[derive(Debug, Deserialize, Serialize, PartialEq)]
46pub struct GitLabNamespace {
47 id: u32,
48 name: String,
49 web_url: String,
50}
51
52#[derive(Debug, Deserialize, Serialize, PartialEq)]
53pub enum GitLabRepositoryVisibility {
54 #[serde(rename = "public")]
55 Public,
56 #[serde(rename = "internal")]
57 Internal,
58 #[serde(rename = "private")]
59 Private,
60}
61
62#[derive(Debug, Deserialize, Serialize)]
63struct GitLabForkedFromRepository {
64 id: u32,
65 name: String,
66 name_with_namespace: String,
67 path: String,
68 path_with_namespace: String,
69 web_url: String,
70}
71
72#[derive(Debug, Deserialize, Serialize)]
73struct GitLabRepository {
74 id: u32,
75 name: String,
76 name_with_namespace: String,
77 path: String,
78 path_with_namespace: String,
79 description: Option<String>,
80 #[serde(with = "time::serde::iso8601")]
81 created_at: OffsetDateTime,
82 #[serde(with = "time::serde::iso8601")]
83 last_activity_at: OffsetDateTime,
84 default_branch: String,
85 web_url: String,
86 ssh_url_to_repo: String,
87 http_url_to_repo: String,
88 forks_count: u32,
89 star_count: u32,
90 archived: bool,
91 visibility: GitLabRepositoryVisibility,
92 owner: Option<GitLabUser>,
93 forked_from_project: Option<GitLabForkedFromRepository>,
94}
95
96impl From<GitLabRepository> for Repository {
97 fn from(repo: GitLabRepository) -> Repository {
98 let GitLabRepository {
99 name,
100 path_with_namespace,
101 description,
102 created_at,
103 default_branch,
104 web_url,
105 ssh_url_to_repo,
106 http_url_to_repo,
107 forks_count,
108 star_count,
109 last_activity_at,
110 archived,
111 visibility,
112 owner,
113 forked_from_project,
114 ..
115 } = repo;
116 Repository {
117 name,
118 full_name: path_with_namespace,
119 owner: owner.map(|o| o.into()),
120 visibility: match visibility {
121 GitLabRepositoryVisibility::Public => RepositoryVisibility::Public,
122 GitLabRepositoryVisibility::Internal => RepositoryVisibility::Internal,
123 GitLabRepositoryVisibility::Private => RepositoryVisibility::Private,
124 },
125 html_url: web_url,
126 description: description.unwrap_or_default(),
127 created_at,
128 updated_at: last_activity_at,
129 archived,
130 default_branch,
131 forks_count,
132 stars_count: star_count,
133 ssh_url: ssh_url_to_repo,
134 https_url: http_url_to_repo,
135 forked_from: forked_from_project.map(ForkedFromRepository::from),
136 }
137 }
138}
139
140impl From<GitLabForkedFromRepository> for ForkedFromRepository {
141 fn from(repo: GitLabForkedFromRepository) -> ForkedFromRepository {
142 let GitLabForkedFromRepository {
143 name,
144 path_with_namespace,
145 web_url,
146 ..
147 } = repo;
148 ForkedFromRepository {
149 name,
150 full_name: path_with_namespace,
151 html_url: web_url,
152 }
153 }
154}
155
156#[derive(Debug, Deserialize, Serialize)]
157struct GitLabCreateRepository {
158 name: String,
159 path: String,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 description: Option<String>,
162 namespace_id: Option<u32>,
163 visibility: GitLabRepositoryVisibility,
164 initialize_with_readme: bool,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 default_branch: Option<String>,
167}
168
169#[derive(Debug, Deserialize, Serialize)]
170struct GitLabForkRepository {
171 #[serde(skip_serializing_if = "Option::is_none")]
172 name: Option<String>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 namespace_path: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 path: Option<String>,
177}
178
179#[derive(Debug, Deserialize, Serialize)]
180struct GitLabRepositoryDeleted {
181 message: String,
182}
183
184#[derive(Debug, Deserialize, Serialize)]
185pub struct GitLabDiffRefs {
186 base_sha: String,
187 head_sha: String,
188 start_sha: String,
189}
190
191#[derive(Debug, Deserialize, Serialize)]
192pub enum GitLabPullRequestState {
193 #[serde(rename = "opened")]
194 Open,
195 #[serde(rename = "closed")]
196 Closed,
197 #[serde(rename = "merged")]
198 Merged,
199 #[serde(rename = "locked")]
200 Locked,
201}
202
203impl From<GitLabPullRequestState> for PullRequestState {
204 fn from(state: GitLabPullRequestState) -> PullRequestState {
205 match state {
206 GitLabPullRequestState::Open => PullRequestState::Open,
207 GitLabPullRequestState::Closed => PullRequestState::Closed,
208 GitLabPullRequestState::Merged => PullRequestState::Merged,
209 GitLabPullRequestState::Locked => PullRequestState::Locked,
210 }
211 }
212}
213
214#[derive(Debug, Deserialize, Serialize)]
215pub struct GitLabPullRequest {
216 pub id: u32,
217 pub iid: u32,
218 pub state: GitLabPullRequestState,
219 pub title: String,
220 pub description: String,
221 pub source_branch: String,
222 pub target_branch: String,
223 pub web_url: String,
224 #[serde(with = "time::serde::iso8601")]
225 pub created_at: OffsetDateTime,
226 #[serde(with = "time::serde::iso8601")]
227 pub updated_at: OffsetDateTime,
228 pub author: GitLabUser,
229 pub closed_by: Option<GitLabUser>,
230 pub reviewers: Option<Vec<GitLabUser>>,
231 pub sha: String,
232 pub diff_refs: Option<GitLabDiffRefs>,
233 pub should_remove_source_branch: Option<bool>,
234 pub force_remove_source_branch: bool,
235}
236
237impl From<GitLabPullRequest> for PullRequest {
238 fn from(pr: GitLabPullRequest) -> PullRequest {
239 let GitLabPullRequest {
240 iid,
241 state,
242 title,
243 description,
244 source_branch,
245 target_branch,
246 web_url,
247 created_at,
248 updated_at,
249 author,
250 closed_by,
251 reviewers,
252 diff_refs,
253 sha,
254 should_remove_source_branch,
255 force_remove_source_branch,
256 ..
257 } = pr;
258 let diff_refs = diff_refs.unwrap_or(GitLabDiffRefs {
259 head_sha: sha,
260 base_sha: String::new(),
262 start_sha: String::new(),
263 });
264 PullRequest {
265 id: iid,
266 state: state.into(),
267 title,
268 description,
269 source: source_branch,
270 source_sha: diff_refs.head_sha,
271 target: target_branch,
272 target_sha: diff_refs.base_sha,
273 url: web_url,
274 created_at,
275 updated_at,
276 author: author.into(),
277 closed_by: closed_by.map(|c| c.into()),
278 reviewers: reviewers.map(|rs| rs.into_iter().map(|r| r.into()).collect()),
279 delete_source_branch: should_remove_source_branch.unwrap_or_default()
280 || force_remove_source_branch,
281 }
282 }
283}
284
285#[derive(Debug, Deserialize, Serialize)]
286pub struct GitLabCreatePullRequest {
287 pub title: String,
288 pub description: String,
289 pub source_branch: String,
290 pub target_branch: String,
291 pub remove_source_branch: bool,
292 pub reviewer_ids: Vec<String>,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub target_project_id: Option<u32>,
295}
296
297impl From<CreatePullRequest> for GitLabCreatePullRequest {
298 fn from(pr: CreatePullRequest) -> Self {
299 let CreatePullRequest {
300 title,
301 description,
302 source,
303 target,
304 close_source_branch,
305 reviewers,
306 } = pr;
307 Self {
308 title,
309 description,
310 source_branch: source,
311 target_branch: target.unwrap_or("master".to_string()),
313 remove_source_branch: close_source_branch,
314 reviewer_ids: reviewers,
315 target_project_id: None,
316 }
317 }
318}
319
320#[derive(Debug, Default, Deserialize, Serialize)]
321pub enum GitLabUpdatePullRequestStateEvent {
322 #[serde(rename = "reopen")]
323 Reopen,
324 #[default]
325 #[serde(rename = "close")]
326 Close,
327}
328
329#[derive(Debug, Default, Deserialize, Serialize)]
330pub struct GitLabUpdatePullRequest {
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub title: Option<String>,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 pub description: Option<String>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 pub source_branch: Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 pub target_branch: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 pub remove_source_branch: Option<bool>,
341 #[serde(skip_serializing_if = "Option::is_none")]
342 pub state_event: Option<GitLabUpdatePullRequestStateEvent>,
343}
344
345#[derive(Debug, Deserialize, Serialize)]
346pub struct GitLabMergePullRequest {
347 pub should_remove_source_branch: bool,
348}
349
350#[derive(Debug)]
351pub struct GitLab {
352 settings: VersionControlSettings,
353 client: Agent,
354 hostname: String,
355 repo: String,
356}
357
358impl GitLab {
359 #[instrument(skip_all)]
360 fn get_repository_url(&self, url: &str) -> String {
361 format!("/projects/{}{}", encode(&self.repo), url)
362 }
363
364 #[instrument(skip_all)]
365 fn call<T: DeserializeOwned, U: Serialize + Debug>(
366 &self,
367 method: &str,
368 url: &str,
369 body: Option<U>,
370 ) -> Result<T> {
371 let url = format!("https://{}/api/v4{}", self.hostname, url);
372
373 info!("Calling with {method} on {url}.");
374
375 let token = &self.settings.auth;
376
377 trace!("Authenticating with token '{token}'.");
378
379 let request = self
380 .client
381 .request(method, &url)
382 .set("Authorization", &format!("Bearer {}", token))
383 .set("Content-Type", "application/json");
384 let result = if let Some(body) = &body {
385 trace!("Sending body: {}.", serde_json::to_string(&body)?);
386 request.send_json(body)
387 } else {
388 request.call()
389 };
390
391 match result {
392 Ok(result) => {
393 let status = result.status();
394 let t = result.into_string()?;
395
396 info!(
397 "Received response with response code {} with body size {}.",
398 status,
399 t.len()
400 );
401 trace!("Response body: {t}.");
402 let t: T = serde_json::from_str(&t)?;
403 Ok(t)
404 }
405 Err(Error::Status(status, result)) => {
406 let t = result.into_string()?;
407
408 info!(
409 "Received response with response code {} with body size {}.",
410 status,
411 t.len()
412 );
413 Err(eyre!("Request failed (response: {}).", t))
414 }
415 Err(Error::Transport(_)) => Err(eyre!("Sending data failed.")),
416 }
417 }
418
419 #[instrument(skip_all)]
420 fn get_repository_data(&self) -> Result<GitLabRepository> {
421 self.call::<GitLabRepository, i32>("GET", &self.get_repository_url(""), None)
422 }
423
424 #[instrument(skip(self))]
425 fn get_user_by_name(&self, username: &str) -> Result<User> {
426 let users: Vec<GitLabUser> = self.call(
427 "GET",
428 &format!("/users?username={username}"),
429 None as Option<i32>,
430 )?;
431
432 match users.into_iter().next() {
433 Some(user) => Ok(user.into()),
434 None => Err(eyre!("User with name {username} not found.")),
435 }
436 }
437}
438
439impl VersionControl for GitLab {
440 #[instrument(skip_all)]
441 fn init(hostname: String, repo: String, settings: VersionControlSettings) -> Self {
442 let client = AgentBuilder::new()
443 .tls_connector(Arc::new(TlsConnector::new().unwrap()))
444 .build();
445 GitLab {
446 settings,
447 client,
448 hostname,
449 repo,
450 }
451 }
452 #[instrument(skip_all)]
453 fn login_url(&self) -> String {
454 format!(
455 "https://{}/-/profile/personal_access_tokens?name=gr&scopes=read_user,api",
456 self.hostname
457 )
458 }
459 #[instrument(skip_all)]
460 fn validate_token(&self, token: &str) -> Result<()> {
461 if !token.starts_with("glpat-") {
462 Err(eyre!("Your GitLab token has to start with 'glpat'."))
463 } else if token.len() != 26 {
464 Err(eyre!("Your GitLab token has to be 26 characters long."))
465 } else {
466 Ok(())
467 }
468 }
469 #[instrument(skip(self))]
470 fn create_pr(&self, mut pr: CreatePullRequest) -> Result<PullRequest> {
471 let reviewers = pr
472 .reviewers
473 .iter()
474 .map(|reviewer| self.get_user_by_name(reviewer))
475 .collect::<Result<Vec<User>>>()?;
476
477 pr.reviewers = reviewers.into_iter().map(|r| r.id).collect();
478
479 pr.target = pr.target.or(self.settings.default_branch.clone());
480 if pr.target.is_none() {
481 let GitLabRepository { default_branch, .. } = self.get_repository_data()?;
482 info!("Using {default_branch} as target branch.");
483 pr.target = Some(default_branch);
484 }
485
486 let mut gitlab_pr = GitLabCreatePullRequest::from(pr);
487 if self.settings.fork {
488 let repo = self.get_repository_data()?;
489 if let Some(forked) = repo.forked_from_project {
490 gitlab_pr.target_project_id = Some(forked.id);
491 }
492 };
493
494 let new_pr: GitLabPullRequest = self.call(
495 "POST",
496 &self.get_repository_url("/merge_requests"),
497 Some(gitlab_pr),
498 )?;
499
500 Ok(new_pr.into())
501 }
502 #[instrument(skip(self))]
503 fn get_pr_by_id(&self, id: u32) -> Result<PullRequest> {
504 let pr: GitLabPullRequest = self.call(
505 "GET",
506 &self.get_repository_url(&format!("/merge_requests/{id}")),
507 None as Option<i32>,
508 )?;
509
510 Ok(pr.into())
511 }
512 #[instrument(skip(self))]
513 fn get_pr_by_branch(&self, branch: &str) -> Result<PullRequest> {
514 let prs: Vec<GitLabPullRequest> = self.call(
515 "GET",
516 &self.get_repository_url(&format!("/merge_requests?source_branch={branch}")),
517 None as Option<i32>,
518 )?;
519
520 match prs.into_iter().next() {
521 Some(pr) => Ok(pr.into()),
522 None => Err(eyre!("Pull request on branch {branch} not found.")),
523 }
524 }
525 #[instrument(skip(self))]
526 fn list_prs(&self, filters: ListPullRequestFilters) -> Result<Vec<PullRequest>> {
527 let scope_param = match filters.author {
528 PullRequestUserFilter::All => "?scope=all",
529 PullRequestUserFilter::Me => "?scope=created_by_me",
530 };
531 let state_param = match filters.state {
532 PullRequestStateFilter::Open => "&state=opened",
533 PullRequestStateFilter::Closed => "&state=closed",
534 PullRequestStateFilter::Merged => "&state=merged",
535 PullRequestStateFilter::Locked => "&state=locked",
536 PullRequestStateFilter::All => "",
537 };
538 let prs: Vec<GitLabPullRequest> = self.call(
539 "GET",
540 &self.get_repository_url(&format!("/merge_requests{scope_param}{state_param}")),
541 None as Option<i32>,
542 )?;
543
544 Ok(prs.into_iter().map(|pr| pr.into()).collect())
545 }
546 #[instrument(skip(self))]
547 fn approve_pr(&self, id: u32) -> Result<()> {
548 let _: GitLabApproval = self.call(
549 "POST",
550 &self.get_repository_url(&format!("/merge_requests/{id}/approve")),
551 None as Option<i32>,
552 )?;
553
554 Ok(())
555 }
556 #[instrument(skip(self))]
557 fn close_pr(&self, id: u32) -> Result<PullRequest> {
558 let closing = GitLabUpdatePullRequest {
559 state_event: Some(GitLabUpdatePullRequestStateEvent::Close),
560 ..GitLabUpdatePullRequest::default()
561 };
562 let pr: GitLabPullRequest = self.call(
563 "PUT",
564 &self.get_repository_url(&format!("/merge_requests/{id}")),
565 Some(closing),
566 )?;
567
568 Ok(pr.into())
569 }
570 #[instrument(skip(self))]
571 fn merge_pr(&self, id: u32, should_remove_source_branch: bool) -> Result<PullRequest> {
572 let pr: GitLabPullRequest = self.call(
573 "PUT",
574 &self.get_repository_url(&format!("/merge_requests/{id}/merge")),
575 Some(GitLabMergePullRequest {
576 should_remove_source_branch,
577 }),
578 )?;
579
580 Ok(pr.into())
581 }
582
583 #[instrument(skip_all)]
584 fn get_repository(&self) -> Result<Repository> {
585 let repo = self.get_repository_data()?;
586
587 Ok(repo.into())
588 }
589 #[instrument(skip_all)]
590 fn create_repository(&self, repo: CreateRepository) -> Result<Repository> {
591 let CreateRepository {
592 name,
593 organization,
594 description,
595 visibility,
596 init,
597 default_branch,
598 gitignore: _,
599 license: _,
600 } = repo;
601
602 let namespace_id = organization.and_then(|org| {
603 self.call::<GitLabNamespace, Option<u32>>(
604 "POST",
605 &format!("/namespaces?search={org}"),
606 None,
607 )
608 .map(|ns| ns.id)
609 .ok()
610 });
611
612 let create_repo = GitLabCreateRepository {
613 path: name.clone(),
614 name,
615 description,
616 namespace_id,
617 initialize_with_readme: init,
618 visibility: match visibility {
619 RepositoryVisibility::Public => GitLabRepositoryVisibility::Public,
620 RepositoryVisibility::Internal => GitLabRepositoryVisibility::Internal,
621 RepositoryVisibility::Private => GitLabRepositoryVisibility::Private,
622 },
623 default_branch,
624 };
625
626 let new_repo: GitLabRepository = self.call("POST", "/projects", Some(create_repo))?;
627
628 Ok(new_repo.into())
629 }
630 #[instrument(skip_all)]
631 fn fork_repository(&self, repo: ForkRepository) -> Result<Repository> {
632 let ForkRepository {
633 name,
634 organization: namespace_path,
635 } = repo;
636 let path = match (&namespace_path, &name) {
637 (Some(ns), Some(n)) => Some(format!("{ns}/{n}")),
638 (None, Some(n)) => Some(n.to_string()),
639 _ => None,
640 };
641
642 let new_repo: GitLabRepository = self.call(
643 "POST",
644 &self.get_repository_url("/fork"),
645 Some(GitLabForkRepository {
646 name,
647 namespace_path,
648 path,
649 }),
650 )?;
651
652 Ok(new_repo.into())
653 }
654
655 #[instrument(skip_all)]
656 fn delete_repository(&self) -> Result<()> {
657 let _: GitLabRepositoryDeleted =
658 self.call("DELETE", &self.get_repository_url(""), None as Option<i32>)?;
659
660 Ok(())
661 }
662}