Skip to main content

release_kit/release_engine/
merge.rs

1//! `rk release merge`: the operator's order to merge the trunk's release
2//! request.
3//!
4//! The verb previews by default and writes nothing: it reads the one open
5//! release request against the trunk, the trunk tip, the request's head,
6//! and the required check for that head. Under `--apply` it dispatches the
7//! landed merge job with exactly those values and a fresh correlation, and
8//! waits for that one run. The job runs as the release bot, proves every
9//! value again, and merges with the head pinned; rk merges nothing itself.
10//!
11//! A release line keeps the merge by a person, so the verb reads the trunk
12//! alone.
13
14use std::time::{Duration, Instant};
15
16use camino::Utf8Path;
17use serde::Serialize;
18use serde_json::{Value, json};
19
20use crate::detect::Forge;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::landing::manifest;
24use crate::profile::ReleaseMode;
25use crate::release_state::{Api, gitlab_project, proposed_version};
26
27use super::Done;
28use super::forge::Client;
29
30/// The report's schema.
31pub const SCHEMA: &str = "rk.release-merge/1";
32
33/// How long the wait sleeps between two reads of the run, by default.
34const WAIT_INTERVAL_MS: u64 = 15_000;
35
36/// The variable that overrides the interval, in milliseconds.
37const WAIT_INTERVAL_VAR: &str = "RK_RELEASE_WAIT_INTERVAL_MS";
38
39/// The prefix of the merge run's name; the correlation follows it.
40pub const RUN_NAME_PREFIX: &str = "rk release merge ";
41
42/// The report: what the verb read, and under `--apply` what the run did.
43#[derive(Debug, Serialize)]
44pub struct Report {
45    /// The schema this document follows.
46    pub schema: &'static str,
47    /// `preview`, or under `--apply` `merged`.
48    pub state: &'static str,
49    /// The release request, by number.
50    pub request: u64,
51    /// The version the request proposes, where its title names one.
52    pub version: Option<String>,
53    /// The trunk.
54    pub trunk: String,
55    /// The trunk tip the merge expects.
56    pub base: String,
57    /// The request head the merge expects.
58    pub head: String,
59    /// The required check, on GitHub; GitLab judges the whole pipeline.
60    pub check: Option<String>,
61    /// What the check or the pipeline reads for the head.
62    pub check_state: String,
63    /// The correlation the dispatch carried, under `--apply`.
64    pub correlation: Option<String>,
65    /// The run or pipeline that merged, under `--apply`.
66    pub run: Option<String>,
67    /// The commit the merge wrote on the trunk, where the forge names it.
68    pub merge_commit: Option<String>,
69}
70
71/// What the verb read before it acts.
72struct Candidate {
73    number: u64,
74    version: Option<String>,
75    head: String,
76    base: String,
77    check: Option<String>,
78    check_state: String,
79    green: bool,
80}
81
82/// The record's facts the verb needs.
83struct Record {
84    forge: Forge,
85    repo: String,
86    trunk: String,
87    shape: &'static str,
88    workflow: &'static str,
89    required_check: String,
90}
91
92/// Preview the trunk's release request, or under `apply` dispatch its
93/// merge and wait up to `timeout` seconds for the run.
94///
95/// # Errors
96///
97/// A refusal where the target releases through no landed automation, where
98/// zero or several release requests stand open against the trunk, where
99/// the check is not green under `apply`, where the forge refuses the
100/// dispatch, or where the run fails or does not conclude in time.
101pub fn run(target: &Utf8Path, apply: bool, timeout: u64) -> Result<Done<Report>, RkError> {
102    let record = record(target)?;
103    let client = Client::resolve(record.forge, target, &record.repo)?;
104    let candidate = candidate(&client, &record)?;
105    let mut lines = vec![
106        format!(
107            "release request {} proposing {} from {} into {}",
108            crate::release_state::request_name(record.forge, candidate.number),
109            candidate.version.as_deref().unwrap_or("an unread version"),
110            candidate.head,
111            record.trunk
112        ),
113        format!("{} at {}", record.trunk, candidate.base),
114        candidate.check.as_ref().map_or_else(
115            || {
116                format!(
117                    "the request's pipeline at the head reads {}",
118                    candidate.check_state
119                )
120            },
121            |name| {
122                format!(
123                    "the check {name} at the head reads {}",
124                    candidate.check_state
125                )
126            },
127        ),
128    ];
129    let mut report = Report {
130        schema: SCHEMA,
131        state: "preview",
132        request: candidate.number,
133        version: candidate.version.clone(),
134        trunk: record.trunk.clone(),
135        base: candidate.base.clone(),
136        head: candidate.head.clone(),
137        check: candidate.check.clone(),
138        check_state: candidate.check_state.clone(),
139        correlation: None,
140        run: None,
141        merge_commit: None,
142    };
143    if !apply {
144        return Ok(Done {
145            report,
146            lines,
147            next: vec![format!("rk release merge --target {target} --apply")],
148        });
149    }
150    if !candidate.green {
151        return Err(RkError::refusal(
152            Diagnostic::new(
153                Reason::StateDrift,
154                format!(
155                    "the head of {} reads {}, so no merge is dispatched",
156                    crate::release_state::request_name(record.forge, candidate.number),
157                    candidate.check_state
158                ),
159            )
160            .expected("a passing check for the request's head")
161            .action("wait for the check, then run rk release merge --apply again")
162            .target_state("nothing was dispatched"),
163        ));
164    }
165    let correlation = correlation();
166    let run = match record.forge {
167        Forge::Github => dispatch_github(&client, &record, &candidate, &correlation)?,
168        Forge::Gitlab => dispatch_gitlab(&client, &record, &candidate)?,
169    };
170    lines.push(format!("dispatched the merge as {run}"));
171    let failed = wait(&client, &record, &correlation, &run, timeout)?;
172    let name = crate::release_state::request_name(record.forge, candidate.number);
173    let merge_commit = outcome(&client, &record, candidate.number, &run, failed, &mut lines)?;
174    lines.push(format!("merged {name} at {}", candidate.head));
175    report.state = "merged";
176    report.correlation = Some(correlation);
177    report.run = Some(run);
178    report.merge_commit.clone_from(&merge_commit);
179    let next = merge_commit.map_or_else(
180        || vec![format!("rk release status --target {target}")],
181        |sha| vec![format!("rk release status --target {target} --wait {sha}")],
182    );
183    Ok(Done {
184        report,
185        lines,
186        next,
187    })
188}
189
190/// What the run did, read from the request itself: the commit the merge
191/// wrote, with a line where the run failed after its merge, or a refusal
192/// where nothing merged or the outcome is unread.
193fn outcome(
194    client: &Client,
195    record: &Record,
196    number: u64,
197    run: &str,
198    failed: Option<String>,
199    lines: &mut Vec<String>,
200) -> Result<Option<String>, RkError> {
201    // A run can fail after its merge, in a cleanup such as the branch
202    // deletion, so the request's own state decides what happened, never
203    // the run's conclusion alone.
204    let name = crate::release_state::request_name(record.forge, number);
205    Ok(match (merged(client, record, number), failed) {
206        (Some((true, sha)), None) => sha,
207        (Some((true, sha)), Some(conclusion)) => {
208            lines.push(format!(
209                "the merge run {run} concluded {conclusion} after {name} merged; its log names the step that failed"
210            ));
211            sha
212        }
213        (Some((false, _)), Some(conclusion)) => {
214            return Err(RkError::refusal(
215                Diagnostic::new(
216                    Reason::StateDrift,
217                    format!("the merge run {run} concluded {conclusion}, and {name} did not merge"),
218                )
219                .expected("a merge run that proves the request and merges it")
220                .action("read the merge job's log on the forge: it names the proof that refused, and rk release merge previews again")
221                .target_state("the release request stays open"),
222            ));
223        }
224        (answer, conclusion) => {
225            return Err(RkError::refusal(
226                Diagnostic::new(
227                    Reason::ObservationIncomplete,
228                    format!(
229                        "the merge run {run} concluded {}, and whether {name} merged {}",
230                        conclusion.as_deref().unwrap_or("success"),
231                        if answer.is_some() {
232                            "reads no"
233                        } else {
234                            "is unread"
235                        }
236                    ),
237                )
238                .action("run rk release status: it reads the request and the last release")
239                .target_state("the merge outcome is unknown"),
240            ));
241        }
242    })
243}
244
245/// The record's facts, refusing a target that lands no release automation.
246fn record(target: &Utf8Path) -> Result<Record, RkError> {
247    let refuse = |message: String, expected: &str| {
248        RkError::refusal(
249            Diagnostic::new(Reason::StateDrift, message)
250                .expected(expected.to_owned())
251                .target_state("nothing was dispatched"),
252        )
253    };
254    if !target.is_dir() {
255        return Err(RkError::missing(
256            Diagnostic::new(
257                Reason::TargetNotFound,
258                format!("target {target} is not a directory"),
259            )
260            .expected("the checkout of a landed target"),
261        ));
262    }
263    // The configuration loads first, so a retired key refuses here with
264    // the diagnostic every verb reports.
265    crate::config::load(target.as_std_path())?;
266    let manifest = manifest::load(target)?.ok_or_else(|| {
267        refuse(
268            format!("no landing at {target}"),
269            "a target release-kit landed",
270        )
271    })?;
272    let release = &manifest.profile.release;
273    if release.mode != ReleaseMode::Automatic {
274        return Err(refuse(
275            "the record selects no automated release, so no merge job is landed".to_owned(),
276            "profile.release.mode = \"automatic\"",
277        ));
278    }
279    let driver = release.driver.as_deref();
280    let shape = crate::projection::release_driver_shape(driver).ok_or_else(|| {
281        refuse(
282            format!("the release driver {driver:?} lands no merge job"),
283            "a rust, bash, or python release driver",
284        )
285    })?;
286    let forge = manifest
287        .profile
288        .forge
289        .as_deref()
290        .and_then(Forge::parse)
291        .ok_or_else(|| {
292            refuse(
293                "the record names no forge this binary merges on".to_owned(),
294                "a github or gitlab forge",
295            )
296        })?;
297    if manifest.parameters.repo.is_empty() {
298        return Err(refuse(
299            "the record names no forge repository".to_owned(),
300            "the project path recorded at landing",
301        ));
302    }
303    Ok(Record {
304        forge,
305        repo: manifest.parameters.repo.clone(),
306        trunk: manifest.git.trunk.clone(),
307        shape,
308        workflow: if driver == Some("rust") {
309            "release-plz.yml"
310        } else {
311            "release.yml"
312        },
313        required_check: manifest.parameters.required_check.clone(),
314    })
315}
316
317/// The one open release request against the trunk, with what the merge
318/// will expect of it.
319fn candidate(client: &Client, record: &Record) -> Result<Candidate, RkError> {
320    let (path, branch, head_of) = match record.forge {
321        Forge::Github => (
322            format!(
323                "repos/{}/pulls?state=open&base={}&per_page=100",
324                record.repo,
325                query(&record.trunk)
326            ),
327            "/head/ref",
328            "/head/sha",
329        ),
330        Forge::Gitlab => (
331            format!(
332                "projects/{}/merge_requests?state=opened&target_branch={}&per_page=100",
333                gitlab_project(&record.repo),
334                query(&record.trunk)
335            ),
336            "/source_branch",
337            "/sha",
338        ),
339    };
340    let open = client.read_all(&path)?;
341    let found: Vec<&Value> = open
342        .iter()
343        .filter(|request| {
344            request
345                .pointer(branch)
346                .and_then(Value::as_str)
347                .is_some_and(|name| name.starts_with(record.shape))
348        })
349        .collect();
350    let number_of = |request: &Value| {
351        request
352            .get(if record.forge == Forge::Github {
353                "number"
354            } else {
355                "iid"
356            })
357            .and_then(Value::as_u64)
358    };
359    let [request] = found.as_slice() else {
360        let names: Vec<String> = found
361            .iter()
362            .filter_map(|request| number_of(request))
363            .map(|n| crate::release_state::request_name(record.forge, n))
364            .collect();
365        return Err(RkError::refusal(
366            Diagnostic::new(
367                Reason::StateDrift,
368                if names.is_empty() {
369                    format!("no release request stands open against {}", record.trunk)
370                } else {
371                    format!(
372                        "{} release requests stand open against {}: {}",
373                        names.len(),
374                        record.trunk,
375                        names.join(", ")
376                    )
377                },
378            )
379            .expected(format!(
380                "exactly one open release request against {}, on a {}* branch",
381                record.trunk, record.shape
382            ))
383            .action("read rk release status; the bot opens the request on the next releasing push")
384            .target_state("nothing was dispatched"),
385        ));
386    };
387    let unread = |what: &str| {
388        RkError::refusal(
389            Diagnostic::new(
390                Reason::ObservationIncomplete,
391                format!("the forge answered no {what}"),
392            )
393            .action("run rk release merge again once the forge answers")
394            .target_state("nothing was dispatched"),
395        )
396    };
397    let number = number_of(request).ok_or_else(|| unread("request number"))?;
398    let head = request
399        .pointer(head_of)
400        .and_then(Value::as_str)
401        .ok_or_else(|| unread("request head"))?
402        .to_owned();
403    let version = request["title"].as_str().and_then(proposed_version);
404    let base = trunk_tip(client, record)?.ok_or_else(|| unread("trunk tip"))?;
405    let (check, check_state, green) = check(client, record, number, &head)?;
406    Ok(Candidate {
407        number,
408        version,
409        head,
410        base,
411        check,
412        check_state,
413        green,
414    })
415}
416
417/// The trunk tip the merge will expect, where the forge names it.
418fn trunk_tip(client: &Client, record: &Record) -> Result<Option<String>, RkError> {
419    Ok(match record.forge {
420        Forge::Github => client.read(&format!(
421            "repos/{}/branches/{}",
422            record.repo,
423            query(&record.trunk)
424        ))?,
425        Forge::Gitlab => client.read(&format!(
426            "projects/{}/repository/branches/{}",
427            gitlab_project(&record.repo),
428            query(&record.trunk)
429        ))?,
430    }
431    .and_then(|branch| {
432        branch
433            .pointer(if record.forge == Forge::Github {
434                "/commit/sha"
435            } else {
436                "/commit/id"
437            })
438            .and_then(Value::as_str)
439            .map(str::to_owned)
440    }))
441}
442
443/// The required check, or on GitLab the head pipeline, for `head`: its
444/// name, what it reads, and whether it passed.
445fn check(
446    client: &Client,
447    record: &Record,
448    number: u64,
449    head: &str,
450) -> Result<(Option<String>, String, bool), RkError> {
451    Ok(match record.forge {
452        Forge::Github => {
453            if record.required_check.is_empty() {
454                return Err(RkError::refusal(
455                    Diagnostic::new(
456                        Reason::ConfigInvalid,
457                        "the record names no required check, so the merge job has nothing to judge"
458                            .to_owned(),
459                    )
460                    .expected("setup.required_check in .release-kit/config.toml")
461                    .action("run rk upgrade --target . --apply, which records the default check")
462                    .target_state("nothing was dispatched"),
463                ));
464            }
465            let runs = client.read(&format!(
466                "repos/{}/commits/{head}/check-runs?check_name={}&filter=latest",
467                record.repo,
468                query(&record.required_check)
469            ))?;
470            let state = runs.as_ref().map_or_else(
471                || "no check runs".to_owned(),
472                |body| match body["check_runs"].as_array().map(Vec::as_slice) {
473                    Some([run]) => format!(
474                        "{} {}",
475                        run["status"].as_str().unwrap_or("unknown"),
476                        run["conclusion"].as_str().unwrap_or("none")
477                    ),
478                    Some(runs) => format!("{} results", runs.len()),
479                    None => "an unread answer".to_owned(),
480                },
481            );
482            let green = state == "completed success";
483            (Some(record.required_check.clone()), state, green)
484        }
485        Forge::Gitlab => {
486            let detail = client
487                .read(&format!(
488                    "projects/{}/merge_requests/{number}",
489                    gitlab_project(&record.repo)
490                ))?
491                .unwrap_or(Value::Null);
492            let pipeline = &detail["head_pipeline"];
493            let state = if pipeline["sha"].as_str() == Some(head) {
494                pipeline["status"].as_str().unwrap_or("unknown").to_owned()
495            } else {
496                "no pipeline for this head".to_owned()
497            };
498            let green = state == "success";
499            (None, state, green)
500        }
501    })
502}
503
504/// Dispatch the GitHub merge job from the trunk's own definition, which is
505/// the trusted one, and name the run the wait follows.
506fn dispatch_github(
507    client: &Client,
508    record: &Record,
509    candidate: &Candidate,
510    correlation: &str,
511) -> Result<String, RkError> {
512    let body = json!({
513        "ref": record.trunk,
514        "inputs": {
515            "action": "merge",
516            "request": candidate.number.to_string(),
517            "base": candidate.base,
518            "head": candidate.head,
519            "correlation": correlation,
520        },
521    });
522    client.write(
523        "POST",
524        &format!(
525            "repos/{}/actions/workflows/{}/dispatches",
526            record.repo, record.workflow
527        ),
528        Some(&body),
529        "the merge dispatch",
530    )?;
531    Ok(format!("{RUN_NAME_PREFIX}{correlation}"))
532}
533
534/// Create the GitLab merge pipeline on the trunk, and name it by its id,
535/// which is the correlation the wait follows.
536fn dispatch_gitlab(
537    client: &Client,
538    record: &Record,
539    candidate: &Candidate,
540) -> Result<String, RkError> {
541    let body = json!({
542        "ref": record.trunk,
543        "variables": [
544            {"key": "RK_RELEASE_ACTION", "value": "merge"},
545            {"key": "RK_MERGE_REQUEST", "value": candidate.number.to_string()},
546            {"key": "RK_MERGE_BASE", "value": candidate.base},
547            {"key": "RK_MERGE_HEAD", "value": candidate.head},
548        ],
549    });
550    let answer = client.write(
551        "POST",
552        &format!("projects/{}/pipeline", gitlab_project(&record.repo)),
553        Some(&body),
554        "the merge pipeline",
555    )?;
556    answer["id"]
557        .as_u64()
558        .map(|id| format!("pipeline {id}"))
559        .ok_or_else(|| {
560            RkError::refusal(
561                Diagnostic::new(
562                    Reason::ObservationIncomplete,
563                    "the forge created the merge pipeline and named no id".to_owned(),
564                )
565                .action("read the project's pipelines on the forge; the merge job reports there")
566                .target_state("a merge pipeline may be running"),
567            )
568        })
569}
570
571/// Wait for the one correlated run to conclude: `None` where it succeeded,
572/// or the conclusion it failed with.
573fn wait(
574    client: &Client,
575    record: &Record,
576    correlation: &str,
577    run: &str,
578    timeout: u64,
579) -> Result<Option<String>, RkError> {
580    let deadline = Instant::now() + Duration::from_secs(timeout);
581    let interval = Duration::from_millis(
582        std::env::var(WAIT_INTERVAL_VAR)
583            .ok()
584            .and_then(|value| value.parse().ok())
585            .unwrap_or(WAIT_INTERVAL_MS),
586    );
587    loop {
588        let outcome = match record.forge {
589            Forge::Github => github_outcome(client, record, correlation),
590            Forge::Gitlab => gitlab_outcome(client, record, run),
591        };
592        match outcome {
593            Some(Ok(())) => return Ok(None),
594            Some(Err(conclusion)) => return Ok(Some(conclusion)),
595            None if Instant::now() >= deadline => {
596                return Err(RkError::refusal(
597                    Diagnostic::new(
598                        Reason::StateDrift,
599                        format!("the merge run {run} did not conclude within {timeout} seconds"),
600                    )
601                    .action("read the merge run on the forge, then rk release status")
602                    .target_state("the merge run may still be running"),
603                ));
604            }
605            None => std::thread::sleep(interval),
606        }
607    }
608}
609
610/// The correlated GitHub run's outcome, or `None` while it is not found or
611/// not finished. Another run of the same workflow never ends the wait.
612fn github_outcome(
613    client: &Client,
614    record: &Record,
615    correlation: &str,
616) -> Option<Result<(), String>> {
617    let Api::Ok(body) = client.api(
618        "GET",
619        &format!(
620            "repos/{}/actions/workflows/{}/runs?event=workflow_dispatch&branch={}&per_page=50",
621            record.repo,
622            record.workflow,
623            query(&record.trunk)
624        ),
625        None,
626    ) else {
627        return None;
628    };
629    let name = format!("{RUN_NAME_PREFIX}{correlation}");
630    let run = body["workflow_runs"]
631        .as_array()?
632        .iter()
633        .find(|run| run["display_title"].as_str() == Some(name.as_str()))?;
634    if run["status"].as_str() != Some("completed") {
635        return None;
636    }
637    match run["conclusion"].as_str() {
638        Some("success") => Some(Ok(())),
639        other => Some(Err(other.unwrap_or("with no conclusion").to_owned())),
640    }
641}
642
643/// The GitLab pipeline's outcome, or `None` while it runs.
644fn gitlab_outcome(client: &Client, record: &Record, run: &str) -> Option<Result<(), String>> {
645    let id = run.strip_prefix("pipeline ")?;
646    let Api::Ok(body) = client.api(
647        "GET",
648        &format!("projects/{}/pipelines/{id}", gitlab_project(&record.repo)),
649        None,
650    ) else {
651        return None;
652    };
653    match body["status"].as_str() {
654        Some("success") => Some(Ok(())),
655        Some(status @ ("failed" | "canceled" | "skipped")) => Some(Err(status.to_owned())),
656        _ => None,
657    }
658}
659
660/// Whether the request merged, and the commit the merge wrote on the
661/// trunk where the forge names it; `None` where the request is unread.
662///
663/// A GitLab fast-forward merge names neither a merge nor a squash commit:
664/// the trunk then stands at the request's own head.
665fn merged(client: &Client, record: &Record, number: u64) -> Option<(bool, Option<String>)> {
666    let path = match record.forge {
667        Forge::Github => format!("repos/{}/pulls/{number}", record.repo),
668        Forge::Gitlab => format!(
669            "projects/{}/merge_requests/{number}",
670            gitlab_project(&record.repo)
671        ),
672    };
673    let Api::Ok(body) = client.api("GET", &path, None) else {
674        return None;
675    };
676    let merged = match record.forge {
677        Forge::Github => body["merged"].as_bool()?,
678        Forge::Gitlab => body["state"].as_str()? == "merged",
679    };
680    let named = |key: &str| {
681        body[key]
682            .as_str()
683            .filter(|sha| !sha.is_empty())
684            .map(str::to_owned)
685    };
686    let sha = named("merge_commit_sha").or_else(|| named("squash_commit_sha"));
687    let sha = match record.forge {
688        Forge::Gitlab => sha.or_else(|| named("sha")),
689        Forge::Github => sha,
690    };
691    Some((merged, sha.filter(|_| merged)))
692}
693
694/// A fresh correlation: the time and the process, so two dispatches in
695/// one second still differ.
696fn correlation() -> String {
697    let nanos = std::time::SystemTime::now()
698        .duration_since(std::time::UNIX_EPOCH)
699        .map_or(0, |elapsed| elapsed.as_nanos());
700    format!("{nanos:x}-{:x}", std::process::id())
701}
702
703/// One query value, percent-encoded so a check or branch name with spaces
704/// or reserved characters stays one value.
705fn query(value: &str) -> String {
706    use std::fmt::Write as _;
707    let mut out = String::with_capacity(value.len());
708    for byte in value.bytes() {
709        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
710            out.push(char::from(byte));
711        } else {
712            let _ = write!(out, "%{byte:02X}");
713        }
714    }
715    out
716}
717
718#[cfg(test)]
719mod tests {
720    use super::query;
721
722    #[test]
723    fn a_query_value_keeps_its_reserved_characters_as_data() {
724        assert_eq!(query("gate"), "gate");
725        assert_eq!(query("build (matrix, 1)"), "build%20%28matrix%2C%201%29");
726        assert_eq!(query("release/1.4"), "release%2F1.4");
727    }
728
729    #[test]
730    fn the_release_merge_schema_snapshot_holds() {
731        let report = super::Report {
732            schema: super::SCHEMA,
733            state: "preview",
734            request: 7,
735            version: Some("0.2.0".to_owned()),
736            trunk: "master".to_owned(),
737            base: "b".repeat(40),
738            head: "a".repeat(40),
739            check: Some("gate".to_owned()),
740            check_state: "completed success".to_owned(),
741            correlation: None,
742            run: None,
743            merge_commit: None,
744        };
745        assert_eq!(
746            serde_json::to_string(&report).expect("the report serializes"),
747            format!(
748                r#"{{"schema":"rk.release-merge/1","state":"preview","request":7,"version":"0.2.0","trunk":"master","base":"{}","head":"{}","check":"gate","check_state":"completed success","correlation":null,"run":null,"merge_commit":null}}"#,
749                "b".repeat(40),
750                "a".repeat(40)
751            )
752        );
753    }
754}