1use super::common::{
3 CreatePullRequest, CreateRepository, ForkRepository, ForkedFromRepository,
4 ListPullRequestFilters, PullRequest, PullRequestState, PullRequestStateFilter, Repository,
5 RepositoryVisibility, User, VersionControl, VersionControlSettings,
6};
7use eyre::{eyre, ContextCompat, Result};
8use native_tls::TlsConnector;
9use serde::{de::DeserializeOwned, Deserialize, Serialize};
10use std::{fmt::Debug, sync::Arc};
11use time::OffsetDateTime;
12use tracing::{info, instrument, trace};
13use ureq::{Agent, AgentBuilder, Error};
14
15#[derive(Debug, Deserialize, Serialize)]
16pub struct GiteaUser {
17 pub id: u32,
18 pub login: String,
19}
20
21impl From<GiteaUser> for User {
22 fn from(user: GiteaUser) -> User {
23 let GiteaUser { id, login, .. } = user;
24 User {
25 id: id.to_string(),
26 username: login,
27 }
28 }
29}
30
31#[derive(Debug, Deserialize, Serialize)]
32pub struct GiteaPullRequestReview {
33 pub id: u32,
34 pub body: u32,
35 pub user: GiteaUser,
36}
37
38#[derive(Debug, Deserialize, Serialize)]
39struct GiteaRepository {
40 name: String,
41 full_name: String,
42 private: bool,
43 owner: GiteaUser,
44 html_url: String,
45 ssh_url: String,
46 clone_url: String,
47 description: Option<String>,
48 #[serde(with = "time::serde::iso8601")]
49 created_at: OffsetDateTime,
50 #[serde(with = "time::serde::iso8601")]
51 updated_at: OffsetDateTime,
52 archived: bool,
53 default_branch: String,
54 stars_count: u32,
55 forks_count: u32,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 parent: Option<Box<GiteaRepository>>,
58}
59
60impl From<GiteaRepository> for Repository {
61 fn from(repo: GiteaRepository) -> Repository {
62 let GiteaRepository {
63 name,
64 full_name,
65 private,
66 owner,
67 html_url,
68 ssh_url,
69 clone_url,
70 description,
71 created_at,
72 updated_at,
73 archived,
74 default_branch,
75 stars_count,
76 forks_count,
77 parent,
78 } = repo;
79 Repository {
80 name,
81 full_name,
82 owner: Some(owner.into()),
83 visibility: if private {
84 RepositoryVisibility::Private
85 } else {
86 RepositoryVisibility::Public
87 },
88 html_url,
89 ssh_url,
90 https_url: clone_url,
91 description: description.unwrap_or_default(),
92 created_at,
93 updated_at,
94 archived,
95 default_branch,
96 stars_count,
97 forks_count,
98 forked_from: parent.map(|r| ForkedFromRepository::from(*r)),
99 }
100 }
101}
102
103impl From<GiteaRepository> for ForkedFromRepository {
104 fn from(repo: GiteaRepository) -> ForkedFromRepository {
105 let GiteaRepository {
106 name,
107 full_name,
108 html_url,
109 ..
110 } = repo;
111 ForkedFromRepository {
112 name,
113 full_name,
114 html_url,
115 }
116 }
117}
118
119#[derive(Debug, Deserialize, Serialize, Default)]
120struct GiteaCreateRepository {
121 name: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 description: Option<String>,
124 private: bool,
125 auto_init: bool,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 default_branch: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 license: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 gitignores: Option<String>,
132}
133
134#[derive(Debug, Deserialize, Serialize)]
135struct GiteaForkRepository {
136 #[serde(skip_serializing_if = "Option::is_none")]
137 name: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 organization: Option<String>,
140}
141
142#[derive(Debug, Deserialize, Serialize)]
143pub enum GiteaPullRequestState {
144 #[serde(rename = "open")]
145 Open,
146 #[serde(rename = "closed")]
147 Closed,
148}
149
150#[derive(Debug, Deserialize, Serialize)]
151pub struct GiteaPullRequestBranch {
152 #[serde(rename = "ref")]
153 pub branch: String,
154 pub sha: String,
155}
156
157#[derive(Debug, Deserialize, Serialize)]
158pub struct GiteaPullRequest {
159 pub id: u32,
160 pub number: u32,
161 pub state: GiteaPullRequestState,
162 pub title: String,
163 pub body: Option<String>,
164 pub head: GiteaPullRequestBranch,
165 pub base: GiteaPullRequestBranch,
166 pub html_url: String,
167 #[serde(with = "time::serde::iso8601")]
168 pub created_at: OffsetDateTime,
169 #[serde(with = "time::serde::iso8601")]
170 pub updated_at: OffsetDateTime,
171 #[serde(with = "time::serde::iso8601::option")]
172 pub merged_at: Option<OffsetDateTime>,
173 pub user: GiteaUser,
174 pub merged_by: Option<GiteaUser>,
175 pub requested_reviewers: Option<Vec<GiteaUser>>,
176}
177
178impl From<GiteaPullRequest> for PullRequest {
179 fn from(pr: GiteaPullRequest) -> PullRequest {
180 let GiteaPullRequest {
181 number,
182 state,
183 title,
184 body,
185 head,
186 base,
187 html_url,
188 created_at,
189 updated_at,
190 merged_at,
191 user,
192 merged_by,
193 requested_reviewers,
194 ..
195 } = pr;
196 PullRequest {
197 id: number,
198 state: match (state, merged_at) {
199 (GiteaPullRequestState::Open, _) => PullRequestState::Open,
200 (GiteaPullRequestState::Closed, Some(_)) => PullRequestState::Merged,
201 (GiteaPullRequestState::Closed, None) => PullRequestState::Closed,
202 },
203 title,
204 description: body.unwrap_or_default(),
205 source: head.branch,
206 target: base.branch,
207 source_sha: head.sha,
208 target_sha: base.sha,
209 url: html_url,
210 created_at,
211 updated_at,
212 author: user.into(),
213 closed_by: merged_by.map(|c| c.into()),
214 reviewers: requested_reviewers.map(|rs| rs.into_iter().map(|r| r.into()).collect()),
215 delete_source_branch: false,
216 }
217 }
218}
219
220#[derive(Debug, Deserialize, Serialize)]
221pub struct GiteaCreatePullRequest {
222 pub title: String,
223 pub body: String,
224 pub head: String,
225 pub base: String,
226}
227
228impl From<CreatePullRequest> for GiteaCreatePullRequest {
229 fn from(pr: CreatePullRequest) -> Self {
230 let CreatePullRequest {
231 title,
232 description,
233 source,
234 target: destination,
235 ..
236 } = pr;
237 Self {
238 title,
239 body: description,
240 head: source,
241 base: destination.unwrap_or("master".to_string()),
243 }
244 }
245}
246
247#[derive(Debug, Deserialize, Serialize)]
248pub enum GiteaCreatePullRequestReviewEvent {
249 #[serde(rename = "APPROVE")]
250 Approve,
251 #[serde(rename = "REQUEST_CHANGES")]
252 RequestChanges,
253 #[serde(rename = "COMMENT")]
254 Comment,
255}
256
257#[derive(Debug, Deserialize, Serialize)]
258pub struct GiteaCreatePullRequestReview {
259 event: GiteaCreatePullRequestReviewEvent,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 body: Option<String>,
262}
263
264#[derive(Debug, Deserialize, Serialize)]
265pub struct GiteaCreatePullRequestReviewers {
266 reviewers: Vec<String>,
267}
268
269#[derive(Debug, Default, Deserialize, Serialize)]
270pub struct GiteaUpdatePullRequest {
271 #[serde(skip_serializing_if = "Option::is_none")]
272 pub title: Option<String>,
273 #[serde(skip_serializing_if = "Option::is_none")]
274 pub body: Option<String>,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub base: Option<String>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub state: Option<GiteaPullRequestState>,
279}
280
281#[derive(Debug, Deserialize, Serialize)]
282pub enum GiteaMergePullRequestDo {
283 #[serde(rename = "merge")]
284 Merge,
285 #[serde(rename = "rebase")]
286 Rebase,
287 #[serde(rename = "rebase-merge")]
288 RebaseMerge,
289 #[serde(rename = "squash")]
290 Squash,
291 #[serde(rename = "manually-merged")]
292 ManuallyMerged,
293}
294
295#[derive(Debug, Deserialize, Serialize)]
296pub struct GiteaMergePullRequest {
297 #[serde(rename = "Do")]
298 pub do_this: GiteaMergePullRequestDo,
299}
300
301#[derive(Debug)]
302pub struct Gitea {
303 hostname: String,
304 settings: VersionControlSettings,
305 client: Agent,
306 repo: String,
307}
308
309impl Gitea {
310 #[instrument(skip_all)]
311 fn get_repository_url(&self, url: &str) -> String {
312 format!("/repos/{}{}", self.repo, url)
313 }
314 #[instrument(skip_all)]
315 fn call<T: DeserializeOwned, U: Serialize + Debug>(
316 &self,
317 method: &str,
318 url: &str,
319 body: Option<U>,
320 ) -> Result<T> {
321 let url = format!("https://{}/api/v1{}", self.hostname, url);
322
323 info!("Calling with {method} on {url}.");
324
325 let token = &self.settings.auth;
326
327 trace!("Authenticating with token '{token}'.");
328
329 let request = self
330 .client
331 .request(method, &url)
332 .set("Authorization", &format!("token {token}"))
333 .set("Content-Type", "application/json");
334 let result = if let Some(body) = &body {
335 trace!("Sending body: {}.", serde_json::to_string(&body)?);
336 request.send_json(body)
337 } else {
338 request.call()
339 };
340
341 match result {
342 Ok(result) => {
343 let status = result.status();
344 let mut t = result.into_string()?;
345
346 info!(
347 "Received response with response code {} with body size {}.",
348 status,
349 t.len()
350 );
351 trace!("Response body: {t}.");
352
353 if t.is_empty() {
355 t = "null".to_string();
356 }
357
358 let t: T = serde_json::from_str(&t)?;
359 Ok(t)
360 }
361 Err(Error::Status(status, result)) => {
362 let t = result.into_string()?;
363
364 info!(
365 "Received response with response code {} with body size {}.",
366 status,
367 t.len()
368 );
369 Err(eyre!("Request failed (response: {}).", t))
370 }
371 Err(Error::Transport(_)) => Err(eyre!("Sending data failed.")),
372 }
373 }
374
375 #[instrument(skip_all)]
376 fn get_repository_data(&self) -> Result<GiteaRepository> {
377 self.call::<GiteaRepository, i32>("GET", &self.get_repository_url(""), None)
378 }
379
380 #[instrument(skip(self))]
381 fn call_paginated<T: DeserializeOwned>(&self, url: &str, params: &str) -> Result<Vec<T>> {
382 let mut collected_values: Vec<T> = vec![];
383 let mut i = 1;
384 loop {
385 info!("Reading page {}.", i);
386
387 let mut page: Vec<T> = self.call(
388 "GET",
389 &format!("{url}?page={i}{params}"),
390 None as Option<i32>,
391 )?;
392
393 if page.is_empty() {
394 break;
395 }
396
397 collected_values.append(&mut page);
398
399 i += 1;
400 }
401 Ok(collected_values)
402 }
403}
404
405impl VersionControl for Gitea {
406 #[instrument(skip_all)]
407 fn init(hostname: String, repo: String, settings: VersionControlSettings) -> Self {
408 let client = AgentBuilder::new()
409 .tls_connector(Arc::new(TlsConnector::new().unwrap()))
410 .build();
411 Gitea {
412 hostname,
413 settings,
414 client,
415 repo,
416 }
417 }
418
419 #[instrument(skip_all)]
420 fn login_url(&self) -> String {
421 format!("https://{}/user/settings/applications", self.hostname)
422 }
423
424 #[instrument(skip_all)]
425 fn validate_token(&self, token: &str) -> Result<()> {
426 if token.len() != 40 {
427 Err(eyre!("Your Gitea token has to be 40 characters long."))
428 } else {
429 Ok(())
430 }
431 }
432
433 #[instrument(skip(self))]
434 fn create_pr(&self, mut pr: CreatePullRequest) -> Result<PullRequest> {
435 let reviewers = pr.reviewers.clone();
436 pr.target = pr.target.or(self.settings.default_branch.clone());
437 if pr.target.is_none() {
438 let GiteaRepository { default_branch, .. } = self.get_repository_data()?;
439 pr.target = Some(default_branch);
440 }
441
442 let mut url = self.get_repository_url("/pulls");
443 let mut gitea_pr = GiteaCreatePullRequest::from(pr);
444 if self.settings.fork {
445 let repo = self.get_repository()?;
446 if let Some(forked) = repo.forked_from {
447 url = format!("/repos/{}/pulls", forked.full_name);
448
449 let namespace = repo
451 .full_name
452 .split('/')
453 .next()
454 .wrap_err(eyre!("Invalid repository name {}.", repo.full_name))?;
455 gitea_pr.head = format!("{}:{}", namespace, gitea_pr.head);
456 }
457 }
458
459 let new_pr: GiteaPullRequest = self.call("POST", &url, Some(gitea_pr))?;
460
461 let _: Vec<GiteaPullRequestReview> = self.call(
462 "POST",
463 &format!("{}/{}/requested_reviewers", url, new_pr.number),
464 Some(GiteaCreatePullRequestReviewers { reviewers }),
465 )?;
466
467 Ok(new_pr.into())
468 }
469
470 #[instrument(skip(self))]
471 fn get_pr_by_id(&self, id: u32) -> Result<PullRequest> {
472 let pr: GiteaPullRequest = self.call(
473 "GET",
474 &self.get_repository_url(&format!("/pulls/{id}")),
475 None as Option<i32>,
476 )?;
477
478 Ok(pr.into())
479 }
480
481 #[instrument(skip(self))]
482 fn get_pr_by_branch(&self, branch: &str) -> Result<PullRequest> {
483 let prs: Vec<GiteaPullRequest> =
484 self.call_paginated(&self.get_repository_url("/pulls"), "&state=all")?;
485
486 prs.into_iter()
487 .find(|pr| pr.head.branch == branch)
488 .map(|pr| pr.into())
489 .wrap_err(eyre!("Pull request on branch {branch} not found."))
490 }
491
492 #[instrument(skip(self))]
493 fn list_prs(&self, filters: ListPullRequestFilters) -> Result<Vec<PullRequest>> {
494 let state = match filters.state {
495 PullRequestStateFilter::Open => "open",
496 PullRequestStateFilter::Closed
497 | PullRequestStateFilter::Merged
498 | PullRequestStateFilter::Locked => "closed",
499 PullRequestStateFilter::All => "all",
500 };
501 let prs: Vec<GiteaPullRequest> = self.call(
502 "GET",
503 &self.get_repository_url(&format!("/pulls?state={state}")),
504 None as Option<i32>,
505 )?;
506
507 Ok(prs.into_iter().map(|pr| pr.into()).collect())
508 }
509
510 #[instrument(skip(self))]
511 fn approve_pr(&self, id: u32) -> Result<()> {
512 self.call(
513 "POST",
514 &self.get_repository_url(&format!("/pulls/{id}/reviews")),
515 Some(GiteaCreatePullRequestReview {
516 event: GiteaCreatePullRequestReviewEvent::Approve,
517 body: None,
518 }),
519 )?;
520
521 Ok(())
522 }
523
524 #[instrument(skip(self))]
525 fn close_pr(&self, id: u32) -> Result<PullRequest> {
526 let closing = GiteaUpdatePullRequest {
527 state: Some(GiteaPullRequestState::Closed),
528 ..GiteaUpdatePullRequest::default()
529 };
530 let pr: GiteaPullRequest = self.call(
531 "PATCH",
532 &self.get_repository_url(&format!("/pulls/{id}")),
533 Some(closing),
534 )?;
535
536 Ok(pr.into())
537 }
538
539 #[instrument(skip(self))]
540 fn merge_pr(&self, id: u32, _: bool) -> Result<PullRequest> {
541 self.call(
542 "POST",
543 &self.get_repository_url(&format!("/pulls/{id}/merge")),
544 Some(GiteaMergePullRequest {
545 do_this: GiteaMergePullRequestDo::Merge,
546 }),
547 )?;
548
549 self.get_pr_by_id(id)
550 }
551
552 #[instrument(skip_all)]
553 fn get_repository(&self) -> Result<Repository> {
554 let repo = self.get_repository_data()?;
555
556 Ok(repo.into())
557 }
558
559 #[instrument(skip_all)]
560 fn create_repository(&self, repo: CreateRepository) -> Result<Repository> {
561 let CreateRepository {
562 name,
563 description,
564 visibility,
565 organization,
566 init,
567 default_branch,
568 gitignore,
569 license,
570 } = repo;
571 let create_repo: GiteaCreateRepository = GiteaCreateRepository {
572 name,
573 description,
574 private: visibility != RepositoryVisibility::Public,
575 auto_init: init,
576 default_branch,
577 gitignores: gitignore,
578 license,
579 };
580 let new_repo: GiteaRepository = if let Some(org) = organization {
581 self.call("POST", &format!("/orgs/{org}/repos"), Some(create_repo))
582 } else {
583 self.call("POST", "/user/repos", Some(create_repo))
584 }?;
585
586 Ok(new_repo.into())
587 }
588
589 #[instrument(skip_all)]
590 fn fork_repository(&self, repo: ForkRepository) -> Result<Repository> {
591 let ForkRepository { name, organization } = repo;
592
593 let new_repo: GiteaRepository = self.call(
594 "POST",
595 &self.get_repository_url("/forks"),
596 Some(GiteaForkRepository { organization, name }),
597 )?;
598
599 Ok(new_repo.into())
600 }
601
602 #[instrument(skip_all)]
603 fn delete_repository(&self) -> Result<()> {
604 self.call("DELETE", &self.get_repository_url(""), None as Option<i32>)?;
605
606 Ok(())
607 }
608}