1use std::{fmt, process::Command};
2
3use anyhow::{Context, Result, anyhow, bail};
4use serde_json::Value;
5
6use crate::{git, stack};
7
8const PROVIDER_KEY: &str = "stack.provider";
9const REMOTE_KEY: &str = "stack.remote";
10const DEFAULT_REMOTE: &str = "origin";
11
12#[derive(Debug, Clone, Copy, Eq, PartialEq)]
13pub enum ProviderKind {
14 GitHub,
15 GitLab,
16}
17
18impl ProviderKind {
19 fn parse(value: &str) -> Option<Self> {
20 match value.to_ascii_lowercase().as_str() {
21 "github" | "gh" => Some(Self::GitHub),
22 "gitlab" | "glab" => Some(Self::GitLab),
23 _ => None,
24 }
25 }
26}
27
28impl fmt::Display for ProviderKind {
29 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 Self::GitHub => write!(formatter, "github"),
32 Self::GitLab => write!(formatter, "gitlab"),
33 }
34 }
35}
36
37#[derive(Debug, Eq, PartialEq)]
38pub struct DetectedProvider {
39 pub kind: ProviderKind,
40 pub source: ProviderSource,
41}
42
43#[derive(Debug, Eq, PartialEq)]
44pub enum ProviderSource {
45 Config,
46 Remote { remote: String, url: String },
47}
48
49impl fmt::Display for ProviderSource {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 Self::Config => write!(formatter, "config"),
53 Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
54 }
55 }
56}
57
58#[derive(Debug, Eq, PartialEq)]
59pub enum ReviewState {
60 Open,
61 Merged,
62 Closed,
63 Unknown(String),
64}
65
66#[derive(Debug, Eq, PartialEq)]
67pub struct ReviewRequest {
68 pub id: String,
69 pub branch: String,
70 pub base: String,
71 pub state: ReviewState,
72 pub url: String,
73}
74
75pub trait ReviewProvider {
76 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
77
78 fn create_review(&self, branch: &str, base: &str) -> Result<String>;
79
80 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
81
82 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
83
84 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
85}
86
87struct GitHubProvider;
88
89struct GitLabProvider;
90
91impl ReviewProvider for GitHubProvider {
92 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
93 let output = command_output(
94 "gh",
95 &[
96 "pr",
97 "list",
98 "--head",
99 branch,
100 "--json",
101 "number,state,baseRefName,headRefName,url",
102 ],
103 )?;
104 if let Some(review) = parse_github_review(&output)? {
105 return Ok(Some(review));
106 }
107
108 let output = command_output(
111 "gh",
112 &[
113 "pr",
114 "list",
115 "--head",
116 branch,
117 "--state",
118 "merged",
119 "--json",
120 "number,state,baseRefName,headRefName,url",
121 ],
122 )?;
123 parse_github_review(&output)
124 }
125
126 fn create_review(&self, branch: &str, base: &str) -> Result<String> {
127 command_output(
128 "gh",
129 &["pr", "create", "--head", branch, "--base", base, "--fill"],
130 )
131 }
132
133 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
134 command_output("gh", &["pr", "edit", review.id_value(), "--base", base])
135 }
136
137 fn review_body(&self, review: &ReviewRequest) -> Result<String> {
138 let output = command_output("gh", &["pr", "view", review.id_value(), "--json", "body"])?;
139 parse_body_field(&output, "body")
140 }
141
142 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
143 command_output("gh", &["pr", "edit", review.id_value(), "--body", body])
144 }
145}
146
147impl ReviewProvider for GitLabProvider {
148 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
149 let output = command_output(
150 "glab",
151 &["mr", "list", "--source-branch", branch, "--output", "json"],
152 )?;
153 if let Some(review) = parse_gitlab_review(&output)? {
154 return Ok(Some(review));
155 }
156
157 let output = command_output(
160 "glab",
161 &[
162 "mr",
163 "list",
164 "--source-branch",
165 branch,
166 "--merged",
167 "--output",
168 "json",
169 ],
170 )?;
171 parse_gitlab_review(&output)
172 }
173
174 fn create_review(&self, branch: &str, base: &str) -> Result<String> {
175 command_output(
176 "glab",
177 &[
178 "mr",
179 "create",
180 "--source-branch",
181 branch,
182 "--target-branch",
183 base,
184 "--fill",
185 ],
186 )
187 }
188
189 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
190 command_output(
191 "glab",
192 &["mr", "update", review.id_value(), "--target-branch", base],
193 )
194 }
195
196 fn review_body(&self, review: &ReviewRequest) -> Result<String> {
197 let output = command_output(
198 "glab",
199 &["mr", "view", review.id_value(), "--output", "json"],
200 )?;
201 parse_body_field(&output, "description")
202 }
203
204 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
205 command_output(
206 "glab",
207 &["mr", "update", review.id_value(), "--description", body],
208 )
209 }
210}
211
212fn parse_body_field(output: &str, field: &str) -> Result<String> {
213 let value: serde_json::Value =
214 serde_json::from_str(output).context("failed to parse provider JSON")?;
215 Ok(value
216 .get(field)
217 .and_then(serde_json::Value::as_str)
218 .unwrap_or_default()
219 .to_owned())
220}
221
222pub fn print_provider() -> Result<()> {
223 let provider = detect_provider()?;
224 println!("{} ({})", provider.kind, provider.source);
225 Ok(())
226}
227
228pub fn print_review(branch: Option<&str>) -> Result<()> {
229 let branch = branch
230 .map(str::to_owned)
231 .map_or_else(git::current_branch, Ok)?;
232 let provider = detect_provider()?;
233 let review_provider = review_provider(provider.kind);
234
235 let Some(review) = review_provider.review_for_branch(&branch)? else {
236 bail!("no {} review found for {branch}", provider.kind);
237 };
238
239 println!(
240 "{} {} -> {} {} {}",
241 review.id, review.branch, review.base, review.state, review.url
242 );
243 Ok(())
244}
245
246pub fn print_status(branch: Option<&str>) -> Result<()> {
247 let branch = branch
248 .map(str::to_owned)
249 .map_or_else(git::current_branch, Ok)?;
250 let parent = stack::parent_for_branch(&branch)?;
251 let children = stack::children_for_branch(&branch)?;
252
253 println!("branch: {branch}");
254 match parent.as_deref() {
255 Some(parent) => println!("parent: {parent}"),
256 None => println!("parent: none"),
257 }
258 if children.is_empty() {
259 println!("children: none");
260 } else {
261 println!("children: {}", children.join(", "));
262 }
263
264 let provider = detect_provider()?;
265 println!("provider: {} ({})", provider.kind, provider.source);
266 let review_provider = review_provider(provider.kind);
267
268 let Some(review) = review_provider.review_for_branch(&branch)? else {
269 println!("review: none");
270 return Ok(());
271 };
272
273 println!(
274 "review: {} {} {} -> {}",
275 review.id, review.state, review.branch, review.base
276 );
277 println!("url: {}", review.url);
278
279 if let Some(parent) = parent
280 && parent != review.base
281 {
282 println!(
283 "warning: review base is {}, local parent is {}",
284 review.base, parent
285 );
286 }
287
288 Ok(())
289}
290
291pub fn sync_stack(branch: Option<&str>, dry_run: bool) -> Result<()> {
292 let branches = match branch {
293 Some(branch) => vec![branch.to_owned()],
294 None => git::local_branches()?,
295 };
296
297 let provider = detect_provider()?;
298 let review_provider = review_provider(provider.kind);
299 let mut synced = 0;
300 let mut skipped = 0;
301
302 for branch in branches {
303 let Some(review) = review_provider.review_for_branch(&branch)? else {
304 println!("skipped {branch}: no {} review found", provider.kind);
305 skipped += 1;
306 continue;
307 };
308
309 if review.branch != branch {
310 println!(
311 "skipped {branch}: {} review belongs to {}",
312 provider.kind, review.branch
313 );
314 skipped += 1;
315 continue;
316 }
317
318 if review.branch == review.base {
319 bail!("refusing to set {branch} as its own stack parent");
320 }
321
322 if !dry_run {
323 git::config_set(&parent_key(&branch), &review.base)?;
324 stack::record_base(&branch, &review.base);
325 }
326 println!(
327 "{} {} -> {} ({})",
328 if dry_run { "would sync" } else { "synced" },
329 review.branch,
330 review.base,
331 review.id
332 );
333 synced += 1;
334 }
335
336 println!(
337 "sync complete: {synced} {}synced, {skipped} skipped",
338 if dry_run { "would be " } else { "" }
339 );
340 Ok(())
341}
342
343pub fn submit(branch: Option<&str>, submit_stack: bool, dry_run: bool) -> Result<()> {
344 let branch = branch
345 .map(str::to_owned)
346 .map_or_else(git::current_branch, Ok)?;
347
348 let branches = if submit_stack {
349 stack::branch_and_descendants(&branch)?
350 } else {
351 vec![branch]
352 };
353
354 let branch_parents = branch_parents(&branches)?;
355
356 let provider = detect_provider()?;
357 let review_provider = review_provider(provider.kind);
358 let mut summary = SubmitSummary::default();
359
360 for (branch, parent) in branch_parents {
361 summary.record(submit_branch(
362 review_provider.as_ref(),
363 &branch,
364 &parent,
365 dry_run,
366 )?);
367
368 if submit_stack {
369 ensure_stack_note(review_provider.as_ref(), &branch, &parent, dry_run)?;
370 }
371 }
372
373 println!(
374 "submit complete: {} created, {} updated, {} skipped",
375 summary.created, summary.updated, summary.skipped
376 );
377 Ok(())
378}
379
380const STACK_NOTE_START: &str = "<!-- git-stk:stack -->";
381const STACK_NOTE_END: &str = "<!-- /git-stk:stack -->";
382
383fn ensure_stack_note(
387 review_provider: &dyn ReviewProvider,
388 branch: &str,
389 parent: &str,
390 dry_run: bool,
391) -> Result<()> {
392 let Some(parent_review) = review_provider.review_for_branch(parent)? else {
395 return Ok(());
396 };
397 let Some(review) = review_provider.review_for_branch(branch)? else {
398 return Ok(());
399 };
400
401 let note = format!("Depends on {}", parent_review.id);
402
403 if dry_run {
404 println!("would note '{note}' in {}", review.id);
405 return Ok(());
406 }
407
408 let body = review_provider.review_body(&review)?;
409 let updated = body_with_stack_note(&body, ¬e);
410 if updated == body {
411 return Ok(());
412 }
413
414 review_provider.update_review_body(&review, &updated)?;
415 println!("noted '{note}' in {}", review.id);
416 Ok(())
417}
418
419fn body_with_stack_note(body: &str, note: &str) -> String {
421 let section = format!("{STACK_NOTE_START}\n{note}\n{STACK_NOTE_END}");
422
423 if let (Some(start), Some(end)) = (body.find(STACK_NOTE_START), body.find(STACK_NOTE_END))
424 && start < end
425 {
426 let mut updated = String::new();
427 updated.push_str(&body[..start]);
428 updated.push_str(§ion);
429 updated.push_str(&body[end + STACK_NOTE_END.len()..]);
430 return updated;
431 }
432
433 if body.trim().is_empty() {
434 section
435 } else {
436 format!("{}\n\n{section}", body.trim_end())
437 }
438}
439
440pub fn cleanup(branch: Option<&str>, dry_run: bool, delete_branch: bool) -> Result<()> {
441 let branch = branch
442 .map(str::to_owned)
443 .map_or_else(git::current_branch, Ok)?;
444 let branches = stack::branch_and_descendants(&branch)?;
445 let current_branch = git::current_branch()?;
446 let provider = detect_provider()?;
447 let review_provider = review_provider(provider.kind);
448 let mut cleaned = 0;
449 let mut skipped = 0;
450
451 for branch in branches {
452 let Some(review) = review_provider.review_for_branch(&branch)? else {
453 println!("skipped {branch}: no {} review found", provider.kind);
454 skipped += 1;
455 continue;
456 };
457
458 if review.state != ReviewState::Merged {
459 println!("skipped {branch}: review {} is {}", review.id, review.state);
460 skipped += 1;
461 continue;
462 }
463
464 cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
465 cleanup_branch_deletion(&branch, ¤t_branch, dry_run, delete_branch)?;
466 cleaned += 1;
467 }
468
469 println!("cleanup complete: {cleaned} cleaned, {skipped} skipped");
470 Ok(())
471}
472
473fn cleanup_merged_branch(
474 review_provider: &dyn ReviewProvider,
475 branch: &str,
476 dry_run: bool,
477) -> Result<()> {
478 let parent = stack::parent_for_branch(branch)?;
479 let descendants = stack::branch_and_descendants(branch)?;
480 let direct_children: Vec<_> = descendants
481 .into_iter()
482 .skip(1)
483 .filter_map(|child| match stack::parent_for_branch(&child) {
484 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
485 Ok(_) => None,
486 Err(error) => Some(Err(error)),
487 })
488 .collect::<Result<_>>()?;
489
490 for child in direct_children {
491 match parent.as_deref() {
492 Some(parent) => {
493 println!(
494 "{} retarget {child} -> {parent}",
495 if dry_run { "would" } else { "will" }
496 );
497 update_child_review_base(review_provider, &child, parent, dry_run)?;
498 if !dry_run {
499 if let Ok(base) = git::merge_base(branch, &child) {
503 stack::set_base_for_branch(&child, &base)?;
504 }
505 stack::set_parent_for_branch(&child, parent)?;
506 }
507 }
508 None => {
509 println!("{} detach {child}", if dry_run { "would" } else { "will" });
510 if !dry_run {
511 stack::unset_parent_for_branch(&child)?;
512 stack::unset_base_for_branch(&child)?;
513 }
514 }
515 }
516 }
517
518 println!("{} detach {branch}", if dry_run { "would" } else { "will" });
519 if !dry_run {
520 stack::unset_parent_for_branch(branch)?;
521 stack::unset_base_for_branch(branch)?;
522 }
523
524 Ok(())
525}
526
527fn cleanup_branch_deletion(
528 branch: &str,
529 current_branch: &str,
530 dry_run: bool,
531 delete_branch: bool,
532) -> Result<()> {
533 if !delete_branch {
534 return Ok(());
535 }
536
537 if branch == current_branch {
538 bail!("refusing to delete currently checked out branch {branch}");
539 }
540
541 println!(
542 "{} delete branch {branch}",
543 if dry_run { "would" } else { "will" }
544 );
545 if !dry_run {
546 git::delete_branch(branch)?;
547 }
548
549 Ok(())
550}
551
552fn update_child_review_base(
553 review_provider: &dyn ReviewProvider,
554 child: &str,
555 parent: &str,
556 dry_run: bool,
557) -> Result<()> {
558 let Some(review) = review_provider.review_for_branch(child)? else {
559 return Ok(());
560 };
561
562 if review.state == ReviewState::Merged || review.base == parent {
563 return Ok(());
564 }
565
566 println!(
567 "{} update review {} -> {} ({})",
568 if dry_run { "would" } else { "will" },
569 review.branch,
570 parent,
571 review.id
572 );
573 if !dry_run {
574 let output = review_provider.update_review_base(&review, parent)?;
575 if !output.is_empty() {
576 println!("{output}");
577 }
578 }
579
580 Ok(())
581}
582
583fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
584 let mut branch_parents = Vec::new();
585 for branch in branches {
586 let Some(parent) = stack::parent_for_branch(branch)? else {
587 bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
588 };
589 branch_parents.push((branch.to_owned(), parent));
590 }
591 Ok(branch_parents)
592}
593
594fn submit_branch(
595 review_provider: &dyn ReviewProvider,
596 branch: &str,
597 parent: &str,
598 dry_run: bool,
599) -> Result<SubmitAction> {
600 if let Some(review) = review_provider.review_for_branch(branch)? {
601 if review.base == parent {
602 if dry_run {
603 println!(
604 "would skip {} -> {} ({})",
605 review.branch, review.base, review.id
606 );
607 } else {
608 println!(
609 "{} already targets {} ({})",
610 review.branch, review.base, review.id
611 );
612 }
613 return Ok(SubmitAction::Skipped);
614 }
615
616 let output = if dry_run {
617 String::new()
618 } else {
619 review_provider.update_review_base(&review, parent)?
620 };
621 println!(
622 "{} {} -> {} ({})",
623 if dry_run { "would update" } else { "updated" },
624 review.branch,
625 parent,
626 review.id
627 );
628 if !output.is_empty() {
629 println!("{output}");
630 }
631 } else {
632 let output = if dry_run {
633 String::new()
634 } else {
635 review_provider.create_review(branch, parent)?
636 };
637 println!(
638 "{} {branch} -> {parent}",
639 if dry_run { "would create" } else { "created" }
640 );
641 if !output.is_empty() {
642 println!("{output}");
643 }
644 return Ok(SubmitAction::Created);
645 }
646
647 Ok(SubmitAction::Updated)
648}
649
650#[derive(Debug, Default)]
651struct SubmitSummary {
652 created: usize,
653 updated: usize,
654 skipped: usize,
655}
656
657impl SubmitSummary {
658 fn record(&mut self, action: SubmitAction) {
659 match action {
660 SubmitAction::Created => self.created += 1,
661 SubmitAction::Updated => self.updated += 1,
662 SubmitAction::Skipped => self.skipped += 1,
663 }
664 }
665}
666
667#[derive(Debug, Clone, Copy, Eq, PartialEq)]
668enum SubmitAction {
669 Created,
670 Updated,
671 Skipped,
672}
673
674pub fn detect_provider() -> Result<DetectedProvider> {
675 if let Some(value) = git::config_get(PROVIDER_KEY)? {
676 let Some(kind) = ProviderKind::parse(&value) else {
677 bail!("unsupported stack.provider value {value:?}; expected github or gitlab");
678 };
679
680 return Ok(DetectedProvider {
681 kind,
682 source: ProviderSource::Config,
683 });
684 }
685
686 let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
687 let Some(url) = git::remote_url(&remote)? else {
688 bail!("could not detect provider: remote {remote:?} does not exist");
689 };
690
691 let Some(kind) = detect_provider_from_url(&url) else {
692 bail!("could not detect provider from remote {remote} ({url})");
693 };
694
695 Ok(DetectedProvider {
696 kind,
697 source: ProviderSource::Remote { remote, url },
698 })
699}
700
701fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
702 let normalized = url.to_ascii_lowercase();
703
704 if normalized.contains("github.com:") || normalized.contains("github.com/") {
705 Some(ProviderKind::GitHub)
706 } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
707 Some(ProviderKind::GitLab)
708 } else {
709 None
710 }
711}
712
713fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
714 match kind {
715 ProviderKind::GitHub => Box::new(GitHubProvider),
716 ProviderKind::GitLab => Box::new(GitLabProvider),
717 }
718}
719
720fn command_output(program: &str, args: &[&str]) -> Result<String> {
721 let output = Command::new(program)
722 .args(args)
723 .output()
724 .with_context(|| format!("failed to run {program}"))?;
725
726 if output.status.success() {
727 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
728 } else {
729 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
730 if stderr.is_empty() {
731 Err(anyhow!("{program} exited with status {}", output.status))
732 } else {
733 Err(anyhow!("{program} failed: {stderr}"))
734 }
735 }
736}
737
738fn parse_github_review(output: &str) -> Result<Option<ReviewRequest>> {
739 let Some(review) = first_json_item(output)? else {
740 return Ok(None);
741 };
742
743 Ok(Some(ReviewRequest {
744 id: format!("#{}", required_string(&review, &["number"])?),
745 branch: required_string(&review, &["headRefName"])?,
746 base: required_string(&review, &["baseRefName"])?,
747 state: parse_state(&required_string(&review, &["state"])?),
748 url: required_string(&review, &["url"])?,
749 }))
750}
751
752fn parse_gitlab_review(output: &str) -> Result<Option<ReviewRequest>> {
753 let Some(review) = first_json_item(output)? else {
754 return Ok(None);
755 };
756
757 Ok(Some(ReviewRequest {
758 id: format!("!{}", required_string(&review, &["iid", "id"])?),
759 branch: required_string(&review, &["source_branch", "sourceBranch"])?,
760 base: required_string(&review, &["target_branch", "targetBranch"])?,
761 state: parse_state(&required_string(&review, &["state"])?),
762 url: required_string(&review, &["web_url", "webUrl", "url"])?,
763 }))
764}
765
766fn first_json_item(output: &str) -> Result<Option<Value>> {
767 let value: Value = serde_json::from_str(output).context("failed to parse provider JSON")?;
768 match value {
769 Value::Array(items) => Ok(items.into_iter().next()),
770 Value::Object(_) => Ok(Some(value)),
771 _ => bail!("provider JSON must be an object or array"),
772 }
773}
774
775fn required_string(value: &Value, keys: &[&str]) -> Result<String> {
776 for key in keys {
777 if let Some(field) = value.get(*key) {
778 if let Some(value) = field.as_str() {
779 return Ok(value.to_owned());
780 }
781 if let Some(value) = field.as_i64() {
782 return Ok(value.to_string());
783 }
784 if let Some(value) = field.as_u64() {
785 return Ok(value.to_string());
786 }
787 }
788 }
789
790 bail!(
791 "provider JSON missing required field: {}",
792 keys.join(" or ")
793 )
794}
795
796fn parse_state(state: &str) -> ReviewState {
797 match state.to_ascii_lowercase().as_str() {
798 "open" | "opened" => ReviewState::Open,
799 "merged" => ReviewState::Merged,
800 "closed" => ReviewState::Closed,
801 _ => ReviewState::Unknown(state.to_owned()),
802 }
803}
804
805fn parent_key(branch: &str) -> String {
806 format!("branch.{branch}.stackParent")
807}
808
809impl fmt::Display for ReviewState {
810 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
811 match self {
812 Self::Open => write!(formatter, "open"),
813 Self::Merged => write!(formatter, "merged"),
814 Self::Closed => write!(formatter, "closed"),
815 Self::Unknown(state) => write!(formatter, "{state}"),
816 }
817 }
818}
819
820impl ReviewRequest {
821 fn id_value(&self) -> &str {
822 self.id
823 .strip_prefix('#')
824 .or_else(|| self.id.strip_prefix('!'))
825 .unwrap_or(&self.id)
826 }
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn parse_github_review_reads_first_array_item() {
835 let review = parse_github_review(
836 r#"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
837 )
838 .expect("parse review")
839 .expect("review exists");
840
841 assert_eq!(
842 review,
843 ReviewRequest {
844 id: "#12".to_owned(),
845 branch: "feature/a".to_owned(),
846 base: "main".to_owned(),
847 state: ReviewState::Open,
848 url: "https://github.com/owner/repo/pull/12".to_owned(),
849 }
850 );
851 }
852
853 #[test]
854 fn parse_gitlab_review_reads_snake_case_fields() {
855 let review = parse_gitlab_review(
856 r#"[{"iid":34,"state":"merged","target_branch":"feature/a","source_branch":"feature/b","web_url":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
857 )
858 .expect("parse review")
859 .expect("review exists");
860
861 assert_eq!(
862 review,
863 ReviewRequest {
864 id: "!34".to_owned(),
865 branch: "feature/b".to_owned(),
866 base: "feature/a".to_owned(),
867 state: ReviewState::Merged,
868 url: "https://gitlab.com/owner/repo/-/merge_requests/34".to_owned(),
869 }
870 );
871 }
872
873 #[test]
874 fn parse_gitlab_review_reads_camel_case_fields() {
875 let review = parse_gitlab_review(
876 r#"[{"id":34,"state":"closed","targetBranch":"feature/a","sourceBranch":"feature/b","webUrl":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
877 )
878 .expect("parse review")
879 .expect("review exists");
880
881 assert_eq!(review.id, "!34");
882 assert_eq!(review.branch, "feature/b");
883 assert_eq!(review.base, "feature/a");
884 assert_eq!(review.state, ReviewState::Closed);
885 assert_eq!(
886 review.url,
887 "https://gitlab.com/owner/repo/-/merge_requests/34"
888 );
889 }
890
891 #[test]
892 fn parse_review_accepts_object_output() {
893 let review = parse_github_review(
894 r#"{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}"#,
895 )
896 .expect("parse review")
897 .expect("review exists");
898
899 assert_eq!(review.id, "#12");
900 }
901
902 #[test]
903 fn parse_review_empty_array_returns_none() {
904 assert_eq!(parse_github_review("[]").expect("parse review"), None);
905 assert_eq!(parse_gitlab_review("[]").expect("parse review"), None);
906 }
907
908 #[test]
909 fn parse_review_errors_on_missing_required_field() {
910 let error = parse_github_review(
911 r#"[{"number":12,"state":"OPEN","baseRefName":"main","url":"https://github.com/owner/repo/pull/12"}]"#,
912 )
913 .expect_err("missing head branch should fail");
914
915 assert!(
916 error
917 .to_string()
918 .contains("provider JSON missing required field: headRefName"),
919 "unexpected error: {error:#}"
920 );
921 }
922
923 #[test]
924 fn parse_review_preserves_unknown_state() {
925 let review = parse_github_review(
926 r#"[{"number":12,"state":"READY_FOR_REVIEW","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
927 )
928 .expect("parse review")
929 .expect("review exists");
930
931 assert_eq!(
932 review.state,
933 ReviewState::Unknown("READY_FOR_REVIEW".to_owned())
934 );
935 }
936
937 #[test]
938 fn body_with_stack_note_appends_to_existing_body() {
939 let updated = body_with_stack_note("Some PR description.\n", "Depends on #12");
940 assert_eq!(
941 updated,
942 "Some PR description.\n\n<!-- git-stk:stack -->\nDepends on #12\n<!-- /git-stk:stack -->"
943 );
944 }
945
946 #[test]
947 fn body_with_stack_note_fills_empty_body() {
948 let updated = body_with_stack_note("", "Depends on !34");
949 assert_eq!(
950 updated,
951 "<!-- git-stk:stack -->\nDepends on !34\n<!-- /git-stk:stack -->"
952 );
953 }
954
955 #[test]
956 fn body_with_stack_note_replaces_existing_note() {
957 let body =
958 "Intro.\n\n<!-- git-stk:stack -->\nDepends on #12\n<!-- /git-stk:stack -->\n\nOutro.";
959 let updated = body_with_stack_note(body, "Depends on #99");
960 assert_eq!(
961 updated,
962 "Intro.\n\n<!-- git-stk:stack -->\nDepends on #99\n<!-- /git-stk:stack -->\n\nOutro."
963 );
964 }
965
966 #[test]
967 fn body_with_stack_note_is_idempotent() {
968 let body = body_with_stack_note("Description.", "Depends on #12");
969 assert_eq!(body_with_stack_note(&body, "Depends on #12"), body);
970 }
971
972 #[test]
973 fn parse_body_field_reads_field_and_defaults_empty() {
974 assert_eq!(
975 parse_body_field(r#"{"body":"hello"}"#, "body").expect("parse body"),
976 "hello"
977 );
978 assert_eq!(
979 parse_body_field(r#"{"description":null}"#, "description").expect("parse body"),
980 ""
981 );
982 assert_eq!(parse_body_field(r#"{}"#, "body").expect("parse body"), "");
983 }
984}