1use std::path::PathBuf;
14use std::process::Stdio;
15
16use camino::Utf8Path;
17use serde_json::{Value, json};
18
19use crate::detect::Forge;
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::error::RkError;
22use crate::release_state::{Api, gitlab_project};
23
24use super::guard::TagAt;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Asset {
29 pub name: String,
31 pub sha256: Option<String>,
33}
34
35#[derive(Debug, Clone)]
37pub struct Release {
38 pub id: String,
41 pub draft: bool,
43 pub assets: Vec<Asset>,
45}
46
47#[derive(Debug, Clone)]
49pub struct Client {
50 cli: PathBuf,
51 cwd: PathBuf,
52 forge: Forge,
53 repo: String,
54}
55
56impl Client {
57 pub fn resolve(forge: Forge, target: &Utf8Path, repo: &str) -> Result<Self, RkError> {
64 Ok(Self {
65 cli: crate::setup::context::resolve_cli(forge)?,
66 cwd: target.as_std_path().to_path_buf(),
67 forge,
68 repo: repo.to_owned(),
69 })
70 }
71
72 #[must_use]
74 pub const fn forge(&self) -> Forge {
75 self.forge
76 }
77
78 fn project(&self) -> String {
80 gitlab_project(&self.repo)
81 }
82
83 fn spawn(&self, args: &[&str], input: Option<&[u8]>) -> Result<std::process::Output, String> {
85 let mut command = std::process::Command::new(&self.cli);
86 command
87 .args(args)
88 .current_dir(&self.cwd)
89 .env("GH_PAGER", "")
90 .env("GLAB_PAGER", "")
91 .env("NO_COLOR", "1")
92 .stdin(if input.is_some() {
93 Stdio::piped()
94 } else {
95 Stdio::null()
96 })
97 .stdout(Stdio::piped())
98 .stderr(Stdio::piped());
99 let mut child = command
100 .spawn()
101 .map_err(|source| format!("the forge CLI did not run: {source}"))?;
102 if let (Some(bytes), Some(mut stdin)) = (input, child.stdin.take()) {
103 use std::io::Write as _;
104 stdin
105 .write_all(bytes)
106 .map_err(|source| format!("the forge CLI did not take its input: {source}"))?;
107 }
108 child
109 .wait_with_output()
110 .map_err(|source| format!("the forge CLI did not finish: {source}"))
111 }
112
113 #[must_use]
115 pub fn api(&self, method: &str, path: &str, body: Option<&Value>) -> Api {
116 self.call(method, path, body, false)
117 }
118
119 fn call(&self, method: &str, path: &str, body: Option<&Value>, paginate: bool) -> Api {
122 let mut args = vec!["api", "--method", method, path];
123 if paginate {
124 args.push("--paginate");
125 }
126 let input = body.map(Value::to_string);
127 if input.is_some() {
128 args.extend(["--input", "-"]);
129 }
130 let output = match self.spawn(&args, input.as_deref().map(str::as_bytes)) {
131 Ok(output) => output,
132 Err(reason) => return Api::Failed(reason),
133 };
134 if output.status.success() {
135 if paginate {
136 return pages(&output.stdout).map_or_else(Api::Failed, Api::Ok);
137 }
138 if output.stdout.iter().all(u8::is_ascii_whitespace) {
139 return Api::Ok(Value::Null);
140 }
141 return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
142 |_| Api::Failed("the forge answer did not parse as JSON".to_owned()),
143 Api::Ok,
144 );
145 }
146 let stderr = String::from_utf8_lossy(&output.stderr);
148 if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
149 return Api::Missing;
150 }
151 Api::Failed(super::last_line(&output.stderr))
152 }
153
154 pub(crate) fn read(&self, path: &str) -> Result<Option<Value>, RkError> {
157 self.read_with(path, false)
158 }
159
160 pub(crate) fn read_all(&self, path: &str) -> Result<Vec<Value>, RkError> {
167 Ok(match self.read_with(path, true)? {
168 Some(Value::Array(items)) => items,
169 _ => Vec::new(),
170 })
171 }
172
173 fn read_with(&self, path: &str, paginate: bool) -> Result<Option<Value>, RkError> {
174 match self.call("GET", path, None, paginate) {
175 Api::Ok(body) => Ok(Some(body)),
176 Api::Missing => Ok(None),
177 Api::Failed(reason) => Err(RkError::refusal(
178 Diagnostic::new(
179 Reason::ObservationIncomplete,
180 format!("the forge did not answer {path}: {reason}"),
181 )
182 .expected(format!(
183 "{} authenticated for {}, with the credential the job sets",
184 self.forge.cli(),
185 self.repo
186 ))
187 .action("rerun the job once the forge answers")
188 .target_state("unchanged"),
189 )),
190 }
191 }
192
193 pub(crate) fn write(
196 &self,
197 method: &str,
198 path: &str,
199 body: Option<&Value>,
200 what: &str,
201 ) -> Result<Value, RkError> {
202 match self.api(method, path, body) {
203 Api::Ok(answer) => Ok(answer),
204 Api::Missing => Err(write_failed(what, "the forge answered 404")),
205 Api::Failed(reason) => Err(write_failed(what, &reason)),
206 }
207 }
208
209 pub fn open_request(&self, base: &str, branch: &str) -> Result<Option<u64>, RkError> {
215 let (path, head, number) = match self.forge {
217 Forge::Github => (
218 format!(
219 "repos/{}/pulls?state=open&head={}:{branch}&base={base}&per_page=100",
220 self.repo,
221 self.repo.split('/').next().unwrap_or_default()
222 ),
223 "/head/ref",
224 "/number",
225 ),
226 Forge::Gitlab => (
227 format!(
228 "projects/{}/merge_requests?state=opened&target_branch={base}&source_branch={}&per_page=100",
229 self.project(),
230 branch.replace('/', "%2F")
231 ),
232 "/source_branch",
233 "/iid",
234 ),
235 };
236 Ok(self
237 .read_all(&path)?
238 .iter()
239 .find(|request| request.pointer(head).and_then(Value::as_str) == Some(branch))
240 .and_then(|request| request.pointer(number)?.as_u64()))
241 }
242
243 pub fn merges_request(&self, sha: &str, base: &str, branch: &str) -> Result<bool, RkError> {
252 let path = match self.forge {
253 Forge::Github => format!("repos/{}/commits/{sha}/pulls?per_page=100", self.repo),
254 Forge::Gitlab => format!(
255 "projects/{}/repository/commits/{sha}/merge_requests?per_page=100",
256 self.project()
257 ),
258 };
259 let requests = self.read_all(&path)?;
260 Ok(requests.iter().any(|request| match self.forge {
261 Forge::Github => {
262 !request["merged_at"].is_null()
263 && request["head"]["ref"].as_str() == Some(branch)
264 && request["base"]["ref"].as_str() == Some(base)
265 && request["merge_commit_sha"].as_str() == Some(sha)
266 }
267 Forge::Gitlab => {
268 let fast_forward =
269 request["merge_commit_sha"].is_null() && request["sha"].as_str() == Some(sha);
270 request["state"].as_str() == Some("merged")
271 && request["source_branch"].as_str() == Some(branch)
272 && request["target_branch"].as_str() == Some(base)
273 && (request["merge_commit_sha"].as_str() == Some(sha)
274 || request["squash_commit_sha"].as_str() == Some(sha)
275 || fast_forward)
276 }
277 }))
278 }
279
280 pub fn release(&self, tag: &str) -> Result<Option<Release>, RkError> {
286 match self.forge {
287 Forge::Github => {
288 let path = format!("repos/{}/releases?per_page=100", self.repo);
291 let Some(release) = self
292 .read_all(&path)?
293 .into_iter()
294 .find(|release| release["tag_name"].as_str() == Some(tag))
295 else {
296 return Ok(None);
297 };
298 let id = release["id"].as_u64().unwrap_or_default().to_string();
299 let assets = self
300 .read_all(&format!(
301 "repos/{}/releases/{id}/assets?per_page=100",
302 self.repo
303 ))?
304 .iter()
305 .filter_map(|asset| {
306 Some(Asset {
307 name: asset["name"].as_str()?.to_owned(),
308 sha256: asset["digest"]
309 .as_str()
310 .and_then(|digest| digest.strip_prefix("sha256:"))
311 .map(str::to_owned),
312 })
313 })
314 .collect();
315 Ok(Some(Release {
316 id,
317 draft: release["draft"].as_bool().unwrap_or(false),
318 assets,
319 }))
320 }
321 Forge::Gitlab => {
322 let path = format!("projects/{}/releases/{tag}", self.project());
323 if self.read(&path)?.is_none() {
324 return Ok(None);
325 }
326 let assets = self
327 .read_all(&format!("{path}/assets/links?per_page=100"))?
328 .iter()
329 .filter_map(|link| {
330 Some(Asset {
331 name: link["name"].as_str()?.to_owned(),
332 sha256: None,
333 })
334 })
335 .collect();
336 Ok(Some(Release {
337 id: tag.to_owned(),
338 draft: false,
339 assets,
340 }))
341 }
342 }
343 }
344
345 #[must_use]
348 pub fn tag(&self, tag: &str) -> TagAt {
349 let path = match self.forge {
350 Forge::Github => format!("repos/{}/git/ref/tags/{tag}", self.repo),
351 Forge::Gitlab => format!("projects/{}/repository/tags/{tag}", self.project()),
352 };
353 let body = match self.api("GET", &path, None) {
354 Api::Ok(body) => body,
355 Api::Missing => return TagAt::Absent,
356 Api::Failed(reason) => return TagAt::Unread(reason),
357 };
358 let commit = match self.forge {
359 Forge::Gitlab => body["commit"]["id"].as_str().map(str::to_owned),
360 Forge::Github if body["object"]["type"].as_str() == Some("tag") => {
361 let Some(object) = body["object"]["sha"].as_str() else {
364 return TagAt::Unread("the tag names no object".to_owned());
365 };
366 match self.api(
367 "GET",
368 &format!("repos/{}/git/tags/{object}", self.repo),
369 None,
370 ) {
371 Api::Ok(tag) => tag["object"]["sha"].as_str().map(str::to_owned),
372 Api::Missing => None,
373 Api::Failed(reason) => return TagAt::Unread(reason),
374 }
375 }
376 Forge::Github => body["object"]["sha"].as_str().map(str::to_owned),
377 };
378 commit.map_or_else(
379 || TagAt::Unread("the tag answers no commit".to_owned()),
380 TagAt::At,
381 )
382 }
383
384 pub fn package_files(&self, version: &str) -> Result<Vec<Asset>, RkError> {
391 let path = format!(
392 "projects/{}/packages?package_type=generic&package_name=release&package_version={version}&per_page=100",
393 self.project()
394 );
395 let id = self.read_all(&path)?.iter().find_map(|package| {
397 (package["package_type"].as_str() == Some("generic")
398 && package["name"].as_str() == Some("release")
399 && package["version"].as_str() == Some(version))
400 .then(|| package["id"].as_u64())
401 .flatten()
402 });
403 let Some(id) = id else {
404 return Ok(Vec::new());
405 };
406 let path = format!(
407 "projects/{}/packages/{id}/package_files?per_page=100",
408 self.project()
409 );
410 Ok(self
411 .read_all(&path)?
412 .iter()
413 .filter_map(|file| {
414 Some(Asset {
415 name: file["file_name"].as_str()?.to_owned(),
416 sha256: file["file_sha256"].as_str().map(str::to_owned),
417 })
418 })
419 .collect())
420 }
421
422 pub fn identity(&self) -> Result<(String, String), RkError> {
436 let unread = || {
437 RkError::refusal(
438 Diagnostic::new(
439 Reason::ForgeAuthentication,
440 "the forge names no account for the job's credential",
441 )
442 .expected("the bot credential the release job sets")
443 .target_state("unchanged"),
444 )
445 };
446 match self.forge {
447 Forge::Github => {
448 let output = self
449 .spawn(
450 &[
451 "api",
452 "graphql",
453 "-f",
454 "query=query { viewer { login databaseId } }",
455 ],
456 None,
457 )
458 .map_err(|reason| write_failed("the bot identity read", &reason))?;
459 let body: Value = serde_json::from_slice(&output.stdout).map_err(|_| unread())?;
460 let login = body["data"]["viewer"]["login"]
461 .as_str()
462 .ok_or_else(unread)?;
463 let id = body["data"]["viewer"]["databaseId"]
464 .as_u64()
465 .ok_or_else(unread)?;
466 Ok((
467 login.to_owned(),
468 format!("{id}+{login}@users.noreply.github.com"),
469 ))
470 }
471 Forge::Gitlab => {
472 let user = self.read("user")?.ok_or_else(unread)?;
473 let name = user["name"].as_str().ok_or_else(unread)?.to_owned();
474 let email =
475 if let Some(email) = user["email"].as_str().filter(|email| !email.is_empty()) {
476 email.to_owned()
477 } else {
478 let host = std::env::var("CI_SERVER_HOST")
479 .unwrap_or_else(|_| "gitlab.com".to_owned());
480 format!(
481 "{}@noreply.{host}",
482 user["username"].as_str().ok_or_else(unread)?
483 )
484 };
485 Ok((name, email))
486 }
487 }
488 }
489
490 pub fn create_request(
496 &self,
497 base: &str,
498 branch: &str,
499 title: &str,
500 body: &str,
501 ) -> Result<u64, RkError> {
502 let answer = match self.forge {
504 Forge::Github => self.write(
505 "POST",
506 &format!("repos/{}/pulls", self.repo),
507 Some(&json!({"title": title, "head": branch, "base": base, "body": body})),
508 "the release request create",
509 )?,
510 Forge::Gitlab => self.write(
511 "POST",
512 &format!("projects/{}/merge_requests", self.project()),
513 Some(&json!({
514 "source_branch": branch, "target_branch": base, "title": title,
515 "description": body, "remove_source_branch": true
516 })),
517 "the release request create",
518 )?,
519 };
520 answer["number"]
521 .as_u64()
522 .or_else(|| answer["iid"].as_u64())
523 .ok_or_else(|| write_failed("the release request create", "the forge named no number"))
524 }
525
526 pub fn edit_request(&self, number: u64, title: &str, body: &str) -> Result<(), RkError> {
532 match self.forge {
534 Forge::Github => self.write(
535 "PATCH",
536 &format!("repos/{}/pulls/{number}", self.repo),
537 Some(&json!({"title": title, "body": body})),
538 "the release request edit",
539 ),
540 Forge::Gitlab => self.write(
541 "PUT",
542 &format!("projects/{}/merge_requests/{number}", self.project()),
543 Some(&json!({"title": title, "description": body})),
544 "the release request edit",
545 ),
546 }
547 .map(drop)
548 }
549
550 pub fn close_request(&self, number: u64) -> Result<(), RkError> {
556 match self.forge {
558 Forge::Github => self.write(
559 "PATCH",
560 &format!("repos/{}/pulls/{number}", self.repo),
561 Some(&json!({"state": "closed"})),
562 "the stale release request close",
563 ),
564 Forge::Gitlab => self.write(
565 "PUT",
566 &format!("projects/{}/merge_requests/{number}", self.project()),
567 Some(&json!({"state_event": "close"})),
568 "the stale release request close",
569 ),
570 }
571 .map(drop)
572 }
573
574 pub fn create_draft(
581 &self,
582 tag: &str,
583 sha: &str,
584 notes: &str,
585 candidate: bool,
586 ) -> Result<String, RkError> {
587 let answer = self.write(
590 "POST",
591 &format!("repos/{}/releases", self.repo),
592 Some(&json!({
593 "tag_name": tag, "target_commitish": sha, "name": tag, "body": notes,
594 "draft": true, "prerelease": candidate
595 })),
596 "the draft release create",
597 )?;
598 answer["id"]
599 .as_u64()
600 .map(|id| id.to_string())
601 .ok_or_else(|| write_failed("the draft release create", "the forge named no id"))
602 }
603
604 pub fn upload(
611 &self,
612 tag: &str,
613 files: &[camino::Utf8PathBuf],
614 clobber: bool,
615 ) -> Result<(), RkError> {
616 if files.is_empty() {
618 return Ok(());
619 }
620 let mut args = vec!["release", "upload", tag];
621 args.extend(files.iter().map(|file| file.as_str()));
622 if clobber {
623 args.push("--clobber");
624 }
625 args.extend(["--repo", self.repo.as_str()]);
626 let output = self
627 .spawn(&args, None)
628 .map_err(|reason| write_failed("the asset upload", &reason))?;
629 if output.status.success() {
630 return Ok(());
631 }
632 Err(write_failed(
633 "the asset upload",
634 &super::last_line(&output.stderr),
635 ))
636 }
637
638 pub fn publish_draft(&self, id: &str, sha: &str, candidate: bool) -> Result<(), RkError> {
644 self.write(
647 "PATCH",
648 &format!("repos/{}/releases/{id}", self.repo),
649 Some(&json!({
650 "draft": false, "target_commitish": sha,
651 "make_latest": if candidate { "false" } else { "true" }
652 })),
653 "the draft release publish",
654 )
655 .map(drop)
656 }
657
658 #[must_use]
660 pub fn package_url(&self, version: &str, name: &str) -> String {
661 let api = std::env::var("CI_API_V4_URL")
662 .ok()
663 .filter(|url| !url.is_empty())
664 .unwrap_or_else(|| "https://gitlab.com/api/v4".to_owned());
665 format!(
666 "{api}/projects/{}/packages/generic/release/{version}/{name}",
667 self.project()
668 )
669 }
670
671 pub fn upload_package(&self, version: &str, file: &Utf8Path) -> Result<(), RkError> {
678 let name = super::file_name(file);
680 let path = format!(
681 "projects/{}/packages/generic/release/{version}/{name}",
682 self.project()
683 );
684 let bytes = std::fs::read(file).map_err(RkError::Io)?;
685 let output = self
686 .spawn(
687 &[
688 "api",
689 "--method",
690 "PUT",
691 &path,
692 "--header",
693 "Content-Type: application/octet-stream",
694 "--input",
695 "-",
696 ],
697 Some(&bytes),
698 )
699 .map_err(|reason| write_failed("the package upload", &reason))?;
700 if output.status.success() {
701 return Ok(());
702 }
703 Err(write_failed(
704 &format!("the package upload of {name}"),
705 &super::last_line(&output.stderr),
706 ))
707 }
708
709 pub fn create_release(
716 &self,
717 tag: &str,
718 sha: &str,
719 description: &str,
720 links: &[(String, String)],
721 ) -> Result<(), RkError> {
722 let links: Vec<Value> = links
724 .iter()
725 .map(|(name, url)| json!({"name": name, "url": url}))
726 .collect();
727 self.write(
728 "POST",
729 &format!("projects/{}/releases", self.project()),
730 Some(&json!({
731 "tag_name": tag, "ref": sha, "name": tag, "description": description,
732 "assets": {"links": links}
733 })),
734 "the release create",
735 )
736 .map(drop)
737 }
738
739 pub fn add_link(&self, tag: &str, name: &str, url: &str) -> Result<(), RkError> {
745 self.write(
747 "POST",
748 &format!("projects/{}/releases/{tag}/assets/links", self.project()),
749 Some(&json!({"name": name, "url": url})),
750 "the release link add",
751 )
752 .map(drop)
753 }
754}
755
756fn pages(stdout: &[u8]) -> Result<Value, String> {
760 let mut items = Vec::new();
761 for page in serde_json::Deserializer::from_slice(stdout).into_iter::<Value>() {
762 match page {
763 Ok(Value::Array(page)) => items.extend(page),
764 Ok(_) => return Err("a page of the forge answer is not a JSON array".to_owned()),
765 Err(_) => return Err("the forge answer did not parse as JSON".to_owned()),
766 }
767 }
768 Ok(Value::Array(items))
769}
770
771fn write_failed(what: &str, reason: &str) -> RkError {
773 RkError::subprocess(
774 Diagnostic::new(Reason::SubprocessFailed, format!("{what} failed: {reason}"))
775 .expected(format!("{what} the forge accepts"))
776 .action("rerun the job: every step keeps what already exists")
777 .target_state("every write before this one stands"),
778 )
779}
780
781#[cfg(test)]
782mod tests {
783 use serde_json::json;
784
785 use super::pages;
786
787 #[test]
788 fn every_page_of_a_list_merges_into_one() {
789 assert_eq!(pages(b"[1,2][3]"), Ok(json!([1, 2, 3])));
790 assert_eq!(pages(b"[1]\n[2]\n"), Ok(json!([1, 2])));
791 assert_eq!(pages(b"[1,2,3]"), Ok(json!([1, 2, 3])));
792 assert_eq!(pages(b""), Ok(json!([])));
793 assert!(pages(b"{}").is_err());
794 assert!(pages(b"[1").is_err());
795 }
796}