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::release_state::{Api, gitlab_project};
23
24use super::guard::TagAt;
25
26/// One asset a release page carries.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Asset {
29    /// The file name.
30    pub name: String,
31    /// Its SHA-256 as the forge reports it, where it reports one.
32    pub sha256: Option<String>,
33}
34
35/// The release of one tag.
36#[derive(Debug, Clone)]
37pub struct Release {
38    /// The forge's handle for it: the numeric id on GitHub, the tag on
39    /// GitLab.
40    pub id: String,
41    /// Whether it is an unpublished draft.
42    pub draft: bool,
43    /// Every asset or link it carries.
44    pub assets: Vec<Asset>,
45}
46
47/// A forge CLI bound to one project.
48#[derive(Debug, Clone)]
49pub struct Client {
50    cli: PathBuf,
51    cwd: PathBuf,
52    forge: Forge,
53    repo: String,
54}
55
56impl Client {
57    /// The client for `forge`, run from `target`, for project `repo`.
58    ///
59    /// # Errors
60    ///
61    /// The resolution's refusal, which names the client, where no usable
62    /// binary resolves.
63    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    /// The forge.
73    #[must_use]
74    pub const fn forge(&self) -> Forge {
75        self.forge
76    }
77
78    /// The GitLab project id, URL-encoded once.
79    fn project(&self) -> String {
80        gitlab_project(&self.repo)
81    }
82
83    /// Run the client with `args`, feeding `input` on its standard input.
84    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    /// One `api` call, with a JSON body where one is given.
114    #[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    /// One `api` call; under `paginate` the client follows every page and
120    /// the answer is every page's items in one array.
121    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        // `gh` renders "HTTP 404", `glab` "404 Not Found".
147        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    /// A read whose failure refuses, because the action cannot decide
155    /// without it; a definite not-found answers `None`.
156    pub(crate) fn read(&self, path: &str) -> Result<Option<Value>, RkError> {
157        self.read_with(path, false)
158    }
159
160    /// Every item of the list at `path`, read page by page; a definite
161    /// not-found answers no item.
162    ///
163    /// # Errors
164    ///
165    /// A refusal where the forge does not answer any page.
166    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    /// A write whose failure is the action's failure, with the client's
194    /// own words.
195    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    /// The open request whose head is `branch` against `base`, by number.
210    ///
211    /// # Errors
212    ///
213    /// A refusal where the forge does not answer.
214    pub fn open_request(&self, base: &str, branch: &str) -> Result<Option<u64>, RkError> {
215        // The head and source-branch filters are exact.
216        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    /// Whether `sha` is the merge or squash commit of a merged request
244    /// whose head is `branch` and whose base is `base`.
245    ///
246    /// SATISFIES release-engine:a-final-version-releases-at-its-request
247    ///
248    /// # Errors
249    ///
250    /// A refusal where the forge does not answer.
251    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    /// The release of `tag`, a draft included, where one exists.
281    ///
282    /// # Errors
283    ///
284    /// A refusal where the forge does not answer.
285    pub fn release(&self, tag: &str) -> Result<Option<Release>, RkError> {
286        match self.forge {
287            Forge::Github => {
288                // The tag endpoint answers no draft, so the list is read
289                // whole.
290                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    /// The commit `tag` names, in three states: present, absent, and a
346    /// read that failed.
347    #[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                // An annotated tag names a tag object, which names the
362                // commit.
363                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    /// Every file the GitLab generic package `release` holds at `version`,
385    /// with its SHA-256.
386    ///
387    /// # Errors
388    ///
389    /// A refusal where the forge does not answer.
390    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        // The name filter is fuzzy, so the exact package is selected.
396        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    /// The name and the email of the account the client's credential
423    /// names.
424    ///
425    /// SATISFIES bot-identity:the-bot-identity-reaches-one-project
426    ///
427    /// GitHub answers the App's bot account as the viewer of its
428    /// installation token, and its email is the account's noreply
429    /// address. GitLab answers the token's user, and a user with no public
430    /// email takes its username's noreply address on the instance.
431    ///
432    /// # Errors
433    ///
434    /// A refusal where the forge does not answer.
435    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    /// Open the request from `branch` into `base`.
491    ///
492    /// # Errors
493    ///
494    /// The forge's refusal.
495    pub fn create_request(
496        &self,
497        base: &str,
498        branch: &str,
499        title: &str,
500        body: &str,
501    ) -> Result<u64, RkError> {
502        // SATISFIES release-engine:one-request-per-base
503        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    /// Edit the request's title and description in place.
527    ///
528    /// # Errors
529    ///
530    /// The forge's refusal.
531    pub fn edit_request(&self, number: u64, title: &str, body: &str) -> Result<(), RkError> {
532        // SATISFIES release-engine:one-request-per-base
533        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    /// Close a request that has nothing left to release.
551    ///
552    /// # Errors
553    ///
554    /// The forge's refusal.
555    pub fn close_request(&self, number: u64) -> Result<(), RkError> {
556        // SATISFIES release-engine:one-request-per-base
557        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    /// Create an unpublished draft release of `tag` at `sha`, which
575    /// creates no tag, and answer its id.
576    ///
577    /// # Errors
578    ///
579    /// The forge's refusal.
580    pub fn create_draft(
581        &self,
582        tag: &str,
583        sha: &str,
584        notes: &str,
585        candidate: bool,
586    ) -> Result<String, RkError> {
587        // SATISFIES release-engine:the-tag-is-created-last
588        // SATISFIES release-engine:a-candidate-is-a-prerelease
589        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    /// Upload files to the GitHub release of `tag`, replacing a same-name
605    /// asset where `clobber` says so.
606    ///
607    /// # Errors
608    ///
609    /// The forge's refusal.
610    pub fn upload(
611        &self,
612        tag: &str,
613        files: &[camino::Utf8PathBuf],
614        clobber: bool,
615    ) -> Result<(), RkError> {
616        // SATISFIES release-engine:every-step-converges
617        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    /// Publish a draft at `sha`; the publication creates the tag there.
639    ///
640    /// # Errors
641    ///
642    /// The forge's refusal.
643    pub fn publish_draft(&self, id: &str, sha: &str, candidate: bool) -> Result<(), RkError> {
644        // SATISFIES release-engine:the-tag-is-created-last
645        // SATISFIES release-engine:a-candidate-is-a-prerelease
646        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    /// The URL a GitLab generic package file is served from.
659    #[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    /// Upload one file to the GitLab generic package `release` at
672    /// `version`.
673    ///
674    /// # Errors
675    ///
676    /// The forge's refusal.
677    pub fn upload_package(&self, version: &str, file: &Utf8Path) -> Result<(), RkError> {
678        // SATISFIES release-engine:every-step-converges
679        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    /// Create the GitLab release of `tag` at `sha` with every link, in one
710    /// request; the release create creates the tag.
711    ///
712    /// # Errors
713    ///
714    /// The forge's refusal.
715    pub fn create_release(
716        &self,
717        tag: &str,
718        sha: &str,
719        description: &str,
720        links: &[(String, String)],
721    ) -> Result<(), RkError> {
722        // SATISFIES release-engine:the-tag-is-created-last
723        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    /// Add one link to the GitLab release of `tag`.
740    ///
741    /// # Errors
742    ///
743    /// The forge's refusal.
744    pub fn add_link(&self, tag: &str, name: &str, url: &str) -> Result<(), RkError> {
745        // SATISFIES release-engine:every-step-converges
746        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
756/// The items of every page a paginated read printed. `gh` prints one JSON
757/// array per page, and `glab` one array of every page's items, so a stream
758/// of arrays is merged here.
759fn 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
771/// A forge write that failed, with the client's own words.
772fn 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}