1use anyhow::Context;
2use reqwest::header::{HeaderMap, HeaderValue};
3use semver::Version;
4use serde::{Deserialize, Serialize};
5
6pub trait RemoteGitEngine {
7 fn connect(&self, owner: &str, repo: &str) -> anyhow::Result<()>;
8
9 fn get_tags(&self, owner: &str, repo: &str) -> anyhow::Result<Vec<Tag>>;
10
11 fn get_commits_since(
12 &self,
13 owner: &str,
14 repo: &str,
15 since_sha: Option<&str>,
16 branch: &str,
17 ) -> anyhow::Result<Vec<Commit>>;
18
19 fn get_pull_request(&self, owner: &str, repo: &str) -> anyhow::Result<Option<usize>>;
20
21 fn create_pull_request(
22 &self,
23 owner: &str,
24 repo: &str,
25 version: &str,
26 body: &str,
27 base: &str,
28 ) -> anyhow::Result<usize>;
29
30 fn update_pull_request(
31 &self,
32 owner: &str,
33 repo: &str,
34 version: &str,
35 body: &str,
36 index: usize,
37 ) -> anyhow::Result<usize>;
38
39 fn create_release(
40 &self,
41 owner: &str,
42 repo: &str,
43 version: &str,
44 body: &str,
45 prerelease: bool,
46 ) -> anyhow::Result<Release>;
47}
48
49pub type DynRemoteGitClient = Box<dyn RemoteGitEngine>;
50
51#[allow(dead_code)]
52pub struct GiteaClient {
53 url: String,
54 token: Option<String>,
55 pub allow_insecure: bool,
56}
57
58const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
59
60impl GiteaClient {
61 pub fn new(url: &str, token: Option<&str>) -> Self {
62 Self {
63 url: url.into(),
64 token: token.map(|t| t.into()),
65 allow_insecure: false,
66 }
67 }
68
69 fn create_client(&self) -> anyhow::Result<reqwest::blocking::Client> {
70 let cb = reqwest::blocking::ClientBuilder::new();
71 let mut header_map = HeaderMap::new();
72 if let Some(token) = &self.token {
73 header_map.insert(
74 "Authorization",
75 HeaderValue::from_str(format!("token {}", token).as_str())?,
76 );
77 }
78
79 let client = cb
80 .user_agent(APP_USER_AGENT)
81 .default_headers(header_map)
82 .danger_accept_invalid_certs(self.allow_insecure)
83 .build()?;
84
85 Ok(client)
86 }
87
88 fn get_commits_since_inner<F>(
89 &self,
90 owner: &str,
91 repo: &str,
92 since_sha: Option<&str>,
93 branch: &str,
94 get_commits: F,
95 ) -> anyhow::Result<Vec<Commit>>
96 where
97 F: Fn(&str, &str, &str, usize) -> anyhow::Result<(Vec<Commit>, bool)>,
98 {
99 let mut commits = Vec::new();
100 let mut page = 1;
101
102 let owner: String = owner.into();
103 let repo: String = repo.into();
104 let since_sha: Option<String> = since_sha.map(|ss| ss.into());
105 let branch: String = branch.into();
106 let mut found_commit = false;
107 loop {
108 let (new_commits, has_more) = get_commits(&owner, &repo, &branch, page)?;
109
110 for commit in new_commits {
111 if let Some(since_sha) = &since_sha {
112 if commit.sha.contains(since_sha) {
113 found_commit = true;
114 } else if !found_commit {
115 commits.push(commit);
116 }
117 } else {
118 commits.push(commit);
119 }
120 }
121
122 if !has_more {
123 break;
124 }
125 page += 1;
126 }
127
128 if !found_commit && since_sha.is_some() {
129 return Err(anyhow::anyhow!(
130 "sha was not found in commit chain: {} on branch: {}",
131 since_sha.unwrap_or("".into()),
132 branch
133 ));
134 }
135
136 Ok(commits)
137 }
138
139 fn get_pull_request_inner<F>(
140 &self,
141 owner: &str,
142 repo: &str,
143 request_pull_request: F,
144 ) -> anyhow::Result<Option<usize>>
145 where
146 F: Fn(&str, &str, usize) -> anyhow::Result<(Vec<PullRequest>, bool)>,
147 {
148 let mut page = 1;
149
150 let owner: String = owner.into();
151 let repo: String = repo.into();
152 loop {
153 let (pull_requests, has_more) = request_pull_request(&owner, &repo, page)?;
154
155 for pull_request in pull_requests {
156 if pull_request.head.r#ref.contains("cuddle-please/release") {
157 return Ok(Some(pull_request.number));
158 }
159 }
160
161 if !has_more {
162 break;
163 }
164 page += 1;
165 }
166
167 Ok(None)
168 }
169}
170
171impl RemoteGitEngine for GiteaClient {
172 fn connect(&self, owner: &str, repo: &str) -> anyhow::Result<()> {
173 let client = self.create_client()?;
174
175 tracing::trace!(owner = &owner, repo = &repo, "gitea connect");
176
177 let request = client
178 .get(format!(
179 "{}/api/v1/repos/{}/{}",
180 &self.url.trim_end_matches('/'),
181 owner,
182 repo
183 ))
184 .build()?;
185
186 let resp = client.execute(request)?;
187
188 if !resp.status().is_success() {
189 resp.error_for_status()?;
190 return Ok(());
191 }
192
193 Ok(())
194 }
195
196 fn get_tags(&self, owner: &str, repo: &str) -> anyhow::Result<Vec<Tag>> {
197 let client = self.create_client()?;
198
199 let request = client
200 .get(format!(
201 "{}/api/v1/repos/{}/{}/tags",
202 &self.url.trim_end_matches('/'),
203 owner,
204 repo
205 ))
206 .build()?;
207
208 let resp = client.execute(request)?;
209
210 if !resp.status().is_success() {
211 return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
212 }
213 let tags: Vec<Tag> = resp.json()?;
214
215 Ok(tags)
216 }
217
218 fn get_commits_since(
219 &self,
220 owner: &str,
221 repo: &str,
222 since_sha: Option<&str>,
223 branch: &str,
224 ) -> anyhow::Result<Vec<Commit>> {
225 let get_commits_since_page = |owner: &str,
226 repo: &str,
227 branch: &str,
228 page: usize|
229 -> anyhow::Result<(Vec<Commit>, bool)> {
230 let client = self.create_client()?;
231 tracing::trace!(
232 owner = owner,
233 repo = repo,
234 branch = branch,
235 page = page,
236 "fetching tags"
237 );
238 let request = client
239 .get(format!(
240 "{}/api/v1/repos/{}/{}/commits?page={}&limit={}&sha={}&stat=false&verification=false&files=false",
241 &self.url.trim_end_matches('/'),
242 owner,
243 repo,
244 page,
245 50,
246 branch,
247 ))
248 .build()?;
249 let resp = client.execute(request)?;
250
251 let mut has_more = false;
252
253 if let Some(gitea_has_more) = resp.headers().get("X-HasMore") {
254 let gitea_has_more = gitea_has_more.to_str()?;
255 if gitea_has_more == "true" || gitea_has_more == "True" {
256 has_more = true;
257 }
258 }
259
260 if !resp.status().is_success() {
261 return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
262 }
263 let commits: Vec<Commit> = resp.json()?;
264
265 Ok((commits, has_more))
266 };
267
268 let commits =
269 self.get_commits_since_inner(owner, repo, since_sha, branch, get_commits_since_page)?;
270
271 Ok(commits)
272 }
273
274 fn get_pull_request(&self, owner: &str, repo: &str) -> anyhow::Result<Option<usize>> {
275 let request_pull_request =
276 |owner: &str, repo: &str, page: usize| -> anyhow::Result<(Vec<PullRequest>, bool)> {
277 let client = self.create_client()?;
278 tracing::trace!(owner = owner, repo = repo, "fetching pull-requests");
279 let request = client
280 .get(format!(
281 "{}/api/v1/repos/{}/{}/pulls?state=open&sort=recentupdate&page={}&limit={}",
282 &self.url.trim_end_matches('/'),
283 owner,
284 repo,
285 page,
286 50,
287 ))
288 .build()?;
289 let resp = client.execute(request)?;
290
291 let mut has_more = false;
292
293 if let Some(gitea_has_more) = resp.headers().get("X-HasMore") {
294 let gitea_has_more = gitea_has_more.to_str()?;
295 if gitea_has_more == "true" || gitea_has_more == "True" {
296 has_more = true;
297 }
298 }
299
300 if !resp.status().is_success() {
301 return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
302 }
303 let commits: Vec<PullRequest> = resp.json()?;
304
305 Ok((commits, has_more))
306 };
307
308 self.get_pull_request_inner(owner, repo, request_pull_request)
309 }
310
311 fn create_pull_request(
312 &self,
313 owner: &str,
314 repo: &str,
315 version: &str,
316 body: &str,
317 base: &str,
318 ) -> anyhow::Result<usize> {
319 #[derive(Clone, Debug, Serialize, Deserialize)]
320 struct CreatePullRequestOption {
321 base: String,
322 body: String,
323 head: String,
324 title: String,
325 }
326
327 let client = self.create_client()?;
328
329 let request = CreatePullRequestOption {
330 base: base.into(),
331 body: body.into(),
332 head: "cuddle-please/release".into(),
333 title: format!("chore(release): {}", version),
334 };
335
336 tracing::trace!(
337 owner = owner,
338 repo = repo,
339 version = version,
340 base = base,
341 "create pull_request"
342 );
343 let request = client
344 .post(format!(
345 "{}/api/v1/repos/{}/{}/pulls",
346 &self.url.trim_end_matches('/'),
347 owner,
348 repo,
349 ))
350 .json(&request)
351 .build()?;
352 let resp = client.execute(request)?;
353
354 if !resp.status().is_success() {
355 return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
356 }
357 let commits: PullRequest = resp.json()?;
358
359 Ok(commits.number)
360 }
361
362 fn update_pull_request(
363 &self,
364 owner: &str,
365 repo: &str,
366 version: &str,
367 body: &str,
368 index: usize,
369 ) -> anyhow::Result<usize> {
370 #[derive(Clone, Debug, Serialize, Deserialize)]
371 struct CreatePullRequestOption {
372 body: String,
373 title: String,
374 }
375
376 let client = self.create_client()?;
377
378 let request = CreatePullRequestOption {
379 body: body.into(),
380 title: format!("chore(release): {}", version),
381 };
382
383 tracing::trace!(
384 owner = owner,
385 repo = repo,
386 version = version,
387 "update pull_request"
388 );
389 let request = client
390 .patch(format!(
391 "{}/api/v1/repos/{}/{}/pulls/{}",
392 &self.url.trim_end_matches('/'),
393 owner,
394 repo,
395 index
396 ))
397 .json(&request)
398 .build()?;
399 let resp = client.execute(request)?;
400
401 if !resp.status().is_success() {
402 return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
403 }
404 let commits: PullRequest = resp.json()?;
405
406 Ok(commits.number)
407 }
408
409 fn create_release(
410 &self,
411 owner: &str,
412 repo: &str,
413 version: &str,
414 body: &str,
415 prerelease: bool,
416 ) -> anyhow::Result<Release> {
417 #[derive(Clone, Debug, Serialize, Deserialize)]
418 struct CreateReleaseOption {
419 body: String,
420 draft: bool,
421 name: String,
422 prerelease: bool,
423 #[serde(alias = "tag_name")]
424 tag_name: String,
425 }
426
427 let client = self.create_client()?;
428
429 let request = CreateReleaseOption {
430 body: body.into(),
431 draft: false,
432 name: version.into(),
433 prerelease,
434 tag_name: version.into(),
435 };
436
437 tracing::trace!(
438 owner = owner,
439 repo = repo,
440 version = version,
441 "create release"
442 );
443 let request = client
444 .post(format!(
445 "{}/api/v1/repos/{}/{}/releases",
446 &self.url.trim_end_matches('/'),
447 owner,
448 repo,
449 ))
450 .json(&request)
451 .build()?;
452 let resp = client.execute(request)?;
453
454 if !resp.status().is_success() {
455 return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
456 }
457 let release: Release = resp.json()?;
458
459 Ok(release)
460 }
461}
462
463#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
464pub struct Release {
465 id: usize,
466 url: String,
467}
468
469#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
470pub struct PullRequest {
471 number: usize,
472 head: PRBranchInfo,
473}
474
475#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
476pub struct PRBranchInfo {
477 #[serde(alias = "ref")]
478 r#ref: String,
479 label: String,
480}
481
482#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
483pub struct Commit {
484 sha: String,
485 pub created: String,
486 pub commit: CommitDetails,
487}
488
489impl Commit {
490 pub fn get_title(&self) -> String {
491 self.commit
492 .message
493 .split('\n')
494 .take(1)
495 .collect::<Vec<&str>>()
496 .join("\n")
497 }
498}
499
500#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
501pub struct CommitDetails {
502 pub message: String,
503}
504
505#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
506pub struct Tag {
507 pub id: String,
508 pub message: String,
509 pub name: String,
510 pub commit: TagCommit,
511}
512
513impl TryFrom<Tag> for Version {
514 type Error = anyhow::Error;
515
516 fn try_from(value: Tag) -> Result<Self, Self::Error> {
517 tracing::trace!(name = &value.name, "parsing tag into version");
518 value
519 .name
520 .parse::<Version>()
521 .context("could not get version from tag")
522 }
523}
524impl TryFrom<&Tag> for Version {
525 type Error = anyhow::Error;
526
527 fn try_from(value: &Tag) -> Result<Self, Self::Error> {
528 tracing::trace!(name = &value.name, "parsing tag into version");
529 value
530 .name
531 .parse::<Version>()
532 .context("could not get version from tag")
533 }
534}
535
536#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
537pub struct TagCommit {
538 pub created: String,
539 pub sha: String,
540 pub url: String,
541}
542
543#[cfg(test)]
544mod test {
545 use tracing_test::traced_test;
546
547 use crate::gitea_client::{Commit, CommitDetails};
548
549 use super::GiteaClient;
550
551 fn get_api_res() -> Vec<Vec<Commit>> {
552 let api_results = vec![
553 vec![Commit {
554 sha: "first-sha".into(),
555 created: "".into(),
556 commit: CommitDetails {
557 message: "first-message".into(),
558 },
559 }],
560 vec![Commit {
561 sha: "second-sha".into(),
562 created: "".into(),
563 commit: CommitDetails {
564 message: "second-message".into(),
565 },
566 }],
567 vec![Commit {
568 sha: "third-sha".into(),
569 created: "".into(),
570 commit: CommitDetails {
571 message: "third-message".into(),
572 },
573 }],
574 ];
575
576 api_results
577 }
578
579 fn get_commits(sha: String) -> anyhow::Result<(Vec<Vec<Commit>>, Vec<Commit>)> {
580 let api_res = get_api_res();
581 let client = GiteaClient::new("", Some(""));
582
583 let commits = client.get_commits_since_inner(
584 "owner",
585 "repo",
586 Some(&sha),
587 "some-branch",
588 |_, _, _, page| -> anyhow::Result<(Vec<Commit>, bool)> {
589 let commit_page = api_res.get(page - 1).unwrap();
590
591 Ok((commit_page.clone(), page != 3))
592 },
593 )?;
594
595 Ok((api_res, commits))
596 }
597
598 #[test]
599 #[traced_test]
600 fn finds_tag_in_list() {
601 let (expected, actual) = get_commits("second-sha".into()).unwrap();
602
603 assert_eq!(
604 expected.get(0).unwrap().clone().as_slice(),
605 actual.as_slice()
606 );
607 }
608
609 #[test]
610 #[traced_test]
611 fn finds_tag_in_list_already_newest_commit() {
612 let (_, actual) = get_commits("first-sha".into()).unwrap();
613
614 assert_eq!(0, actual.len());
615 }
616
617 #[test]
618 #[traced_test]
619 fn finds_tag_in_list_is_base() {
620 let (expected, actual) = get_commits("third-sha".into()).unwrap();
621
622 assert_eq!(expected[0..=1].concat().as_slice(), actual.as_slice());
623 }
624
625 #[test]
626 #[traced_test]
627 fn finds_didnt_find_tag_in_list() {
628 let error = get_commits("not-found-sha".into()).unwrap_err();
629
630 assert_eq!(
631 "sha was not found in commit chain: not-found-sha on branch: some-branch",
632 error.to_string()
633 );
634 }
635}