Skip to main content

release_kit/release_engine/
forge.rs

1//! The forge reads and writes of the release engine, through `gh` and
2//! `glab`, the clients `rk release status` reads through.
3//!
4//! rk keeps no HTTP client and holds no token: each client reads its own
5//! credential from the environment the CI job sets, `GH_TOKEN` on GitHub
6//! and `GITLAB_TOKEN` on GitLab. A JSON body travels on the client's
7//! standard input, so no body is written to disk. A read that fails is
8//! never read as an absence: only a definite not-found answers `None`. A
9//! list is read whole: an exact filter where the forge offers one, and
10//! every page through the client's `--paginate`, because a first page
11//! proves no absence.
12
13use 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/// One asset a release page carries.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Asset {
30    /// The file name.
31    pub name: String,
32    /// Its SHA-256 as the forge reports it, where it reports one.
33    pub sha256: Option<String>,
34}
35
36/// The release of one tag.
37#[derive(Debug, Clone)]
38pub struct Release {
39    /// The forge's handle for it: the numeric id on GitHub, the tag on
40    /// GitLab.
41    pub id: String,
42    /// Whether it is an unpublished draft.
43    pub draft: bool,
44    /// Every asset or link it carries.
45    pub assets: Vec<Asset>,
46}
47
48/// A forge CLI bound to one project.
49#[derive(Debug, Clone)]
50pub struct Client {
51    cli: PathBuf,
52    cwd: PathBuf,
53    forge: Forge,
54    repo: String,
55}
56
57impl Client {
58    /// The client for `forge`, run from `target`, for project `repo`.
59    ///
60    /// # Errors
61    ///
62    /// The resolution's refusal, which names the client, where no usable
63    /// binary resolves.
64    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    /// The forge.
74    #[must_use]
75    pub const fn forge(&self) -> Forge {
76        self.forge
77    }
78
79    /// The GitLab project id, URL-encoded once.
80    fn project(&self) -> String {
81        gitlab_project(&self.repo)
82    }
83
84    /// Run the client with `args`, feeding `input` on its standard input.
85    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    /// One `api` call, with a JSON body where one is given.
115    #[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    /// One `api` call; under `paginate` the client follows every page and
121    /// the answer is every page's items in one array.
122    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        // `gh` renders "HTTP 404", `glab` "404 Not Found".
148        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    /// A read whose failure refuses, because the action cannot decide
156    /// without it; a definite not-found answers `None`.
157    fn read(&self, path: &str) -> Result<Option<Value>, RkError> {
158        self.read_with(path, false)
159    }
160
161    /// Every item of the list at `path`, read page by page; a definite
162    /// not-found answers no item.
163    ///
164    /// # Errors
165    ///
166    /// A refusal where the forge does not answer any page.
167    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    /// A write whose failure is the action's failure, with the client's
195    /// own words.
196    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    /// The open request whose head is `branch` against `base`, by number.
211    ///
212    /// # Errors
213    ///
214    /// A refusal where the forge does not answer.
215    pub fn open_request(&self, base: &str, branch: &str) -> Result<Option<u64>, RkError> {
216        // The head and source-branch filters are exact.
217        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    /// Whether `sha` is the merge or squash commit of a merged request
245    /// whose head is `branch` and whose base is `base`.
246    ///
247    /// SATISFIES release-engine:a-final-version-releases-at-its-request
248    ///
249    /// # Errors
250    ///
251    /// A refusal where the forge does not answer.
252    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    /// The release of `tag`, a draft included, where one exists.
282    ///
283    /// # Errors
284    ///
285    /// A refusal where the forge does not answer.
286    pub fn release(&self, tag: &str) -> Result<Option<Release>, RkError> {
287        match self.forge {
288            Forge::Github => {
289                // The tag endpoint answers no draft, so the list is read
290                // whole.
291                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    /// The commit `tag` names, in three states: present, absent, and a
347    /// read that failed.
348    #[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                // An annotated tag names a tag object, which names the
363                // commit.
364                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    /// Every file the GitLab generic package `release` holds at `version`,
386    /// with its SHA-256.
387    ///
388    /// # Errors
389    ///
390    /// A refusal where the forge does not answer.
391    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        // The name filter is fuzzy, so the exact package is selected.
397        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    /// The name and the email of the account the client's credential
424    /// names.
425    ///
426    /// SATISFIES bot-identity:the-bot-identity-reaches-one-project
427    ///
428    /// GitHub answers the App's bot account as the viewer of its
429    /// installation token, and its email is the account's noreply
430    /// address. GitLab answers the token's user, and a user with no public
431    /// email takes its username's noreply address on the instance.
432    ///
433    /// # Errors
434    ///
435    /// A refusal where the forge does not answer.
436    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    /// Open the request from `branch` into `base`.
492    ///
493    /// # Errors
494    ///
495    /// The forge's refusal.
496    pub fn create_request(
497        &self,
498        base: &str,
499        branch: &str,
500        title: &str,
501        body: &str,
502    ) -> Result<u64, RkError> {
503        // SATISFIES release-engine:one-request-per-base
504        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    /// Edit the request's title and description in place.
528    ///
529    /// # Errors
530    ///
531    /// The forge's refusal.
532    pub fn edit_request(&self, number: u64, title: &str, body: &str) -> Result<(), RkError> {
533        // SATISFIES release-engine:one-request-per-base
534        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    /// Close a request that has nothing left to release.
552    ///
553    /// # Errors
554    ///
555    /// The forge's refusal.
556    pub fn close_request(&self, number: u64) -> Result<(), RkError> {
557        // SATISFIES release-engine:one-request-per-base
558        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    /// Arm the request to merge itself once its checks pass. On GitLab an
576    /// arm the forge cannot take yet, before the request's first pipeline
577    /// starts, answers `false` and waits for the next refresh.
578    ///
579    /// # Errors
580    ///
581    /// GitHub's refusal.
582    pub fn arm(&self, number: u64, form: MergeForm) -> Result<bool, RkError> {
583        // SATISFIES landing:the-arming-identity-is-the-bot
584        // SATISFIES release-engine:the-request-arms-where-the-projection-arms
585        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    /// Create an unpublished draft release of `tag` at `sha`, which
625    /// creates no tag, and answer its id.
626    ///
627    /// # Errors
628    ///
629    /// The forge's refusal.
630    pub fn create_draft(
631        &self,
632        tag: &str,
633        sha: &str,
634        notes: &str,
635        candidate: bool,
636    ) -> Result<String, RkError> {
637        // SATISFIES release-engine:the-tag-is-created-last
638        // SATISFIES release-engine:a-candidate-is-a-prerelease
639        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    /// Upload files to the GitHub release of `tag`, replacing a same-name
655    /// asset where `clobber` says so.
656    ///
657    /// # Errors
658    ///
659    /// The forge's refusal.
660    pub fn upload(
661        &self,
662        tag: &str,
663        files: &[camino::Utf8PathBuf],
664        clobber: bool,
665    ) -> Result<(), RkError> {
666        // SATISFIES release-engine:every-step-converges
667        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    /// Publish a draft at `sha`; the publication creates the tag there.
689    ///
690    /// # Errors
691    ///
692    /// The forge's refusal.
693    pub fn publish_draft(&self, id: &str, sha: &str, candidate: bool) -> Result<(), RkError> {
694        // SATISFIES release-engine:the-tag-is-created-last
695        // SATISFIES release-engine:a-candidate-is-a-prerelease
696        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    /// The URL a GitLab generic package file is served from.
709    #[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    /// Upload one file to the GitLab generic package `release` at
722    /// `version`.
723    ///
724    /// # Errors
725    ///
726    /// The forge's refusal.
727    pub fn upload_package(&self, version: &str, file: &Utf8Path) -> Result<(), RkError> {
728        // SATISFIES release-engine:every-step-converges
729        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    /// Create the GitLab release of `tag` at `sha` with every link, in one
760    /// request; the release create creates the tag.
761    ///
762    /// # Errors
763    ///
764    /// The forge's refusal.
765    pub fn create_release(
766        &self,
767        tag: &str,
768        sha: &str,
769        description: &str,
770        links: &[(String, String)],
771    ) -> Result<(), RkError> {
772        // SATISFIES release-engine:the-tag-is-created-last
773        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    /// Add one link to the GitLab release of `tag`.
790    ///
791    /// # Errors
792    ///
793    /// The forge's refusal.
794    pub fn add_link(&self, tag: &str, name: &str, url: &str) -> Result<(), RkError> {
795        // SATISFIES release-engine:every-step-converges
796        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
806/// The items of every page a paginated read printed. `gh` prints one JSON
807/// array per page, and `glab` one array of every page's items, so a stream
808/// of arrays is merged here.
809fn 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
821/// A forge write that failed, with the client's own words.
822fn 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}