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