1use 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
30pub const SCHEMA: &str = "rk.release-merge/1";
32
33const WAIT_INTERVAL_MS: u64 = 15_000;
35
36const WAIT_INTERVAL_VAR: &str = "RK_RELEASE_WAIT_INTERVAL_MS";
38
39pub const RUN_NAME_PREFIX: &str = "rk release merge ";
41
42#[derive(Debug, Serialize)]
44pub struct Report {
45 pub schema: &'static str,
47 pub state: &'static str,
49 pub request: u64,
51 pub version: Option<String>,
53 pub trunk: String,
55 pub base: String,
57 pub head: String,
59 pub check: Option<String>,
61 pub check_state: String,
63 pub correlation: Option<String>,
65 pub run: Option<String>,
67 pub merge_commit: Option<String>,
69}
70
71struct 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
82struct Record {
84 forge: Forge,
85 repo: String,
86 trunk: String,
87 shape: &'static str,
88 workflow: &'static str,
89 required_check: String,
90}
91
92pub 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
190fn 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 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
245fn 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 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
317fn 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
417fn 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
443fn 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
504fn 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
534fn 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
571fn 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
610fn 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
643fn 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
660fn 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
694fn 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
703fn 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}