1use crate::bitbucket::client::BitbucketClient;
2use crate::errors::{CascadeError, Result};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6use tracing::{debug, info};
7
8pub struct PullRequestManager {
10 client: BitbucketClient,
11}
12
13impl PullRequestManager {
14 pub fn new(client: BitbucketClient) -> Self {
16 Self { client }
17 }
18
19 pub async fn create_pull_request(
21 &self,
22 request: CreatePullRequestRequest,
23 ) -> Result<PullRequest> {
24 debug!(
28 "PR Request - Title: '{}', Description: {:?}, Draft: {}",
29 request.title, request.description, request.draft
30 );
31
32 let pr: PullRequest = self.client.post("pull-requests", &request).await?;
33
34 Ok(pr)
36 }
37
38 pub async fn get_pull_request(&self, pr_id: u64) -> Result<PullRequest> {
40 self.client.get(&format!("pull-requests/{pr_id}")).await
41 }
42
43 pub async fn update_pull_request(
45 &self,
46 pr_id: u64,
47 title: Option<String>,
48 description: Option<String>,
49 version: u64,
50 ) -> Result<PullRequest> {
51 #[derive(Debug, Serialize)]
52 struct UpdatePullRequestRequest {
53 #[serde(skip_serializing_if = "Option::is_none")]
54 title: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 description: Option<String>,
57 version: u64,
58 }
59
60 let request = UpdatePullRequestRequest {
61 title,
62 description,
63 version,
64 };
65
66 self.client
67 .put(&format!("pull-requests/{pr_id}"), &request)
68 .await
69 }
70
71 pub async fn list_pull_requests(
73 &self,
74 state: Option<PullRequestState>,
75 ) -> Result<PullRequestPage> {
76 let mut path = "pull-requests".to_string();
77
78 if let Some(state) = state {
79 path.push_str(&format!("?state={}", state.as_str()));
80 }
81
82 self.client.get(&path).await
83 }
84
85 pub async fn update_source_branch(
88 &self,
89 old_pr_id: u64,
90 new_request: CreatePullRequestRequest,
91 close_reason: Option<String>,
92 ) -> Result<PullRequest> {
93 info!(
94 "Updating PR #{} source branch: {} -> {}",
95 old_pr_id, old_pr_id, new_request.from_ref.display_id
96 );
97
98 let old_pr = self.get_pull_request(old_pr_id).await?;
100
101 let close_message = close_reason.unwrap_or_else(|| {
103 format!(
104 "Superseded by updated branch: {}",
105 new_request.from_ref.display_id
106 )
107 });
108
109 self.decline_pull_request(old_pr_id, &close_message).await?;
110
111 let new_request = CreatePullRequestRequest {
113 title: format!("{} (Updated)", old_pr.title),
114 description: old_pr.description.clone(),
115 from_ref: new_request.from_ref,
116 to_ref: new_request.to_ref,
117 draft: new_request.draft,
118 };
119
120 let new_pr = self.create_pull_request(new_request).await?;
121
122 info!("Closed PR #{} and created new PR #{}", old_pr_id, new_pr.id);
123 Ok(new_pr)
124 }
125
126 pub async fn decline_pull_request(&self, pr_id: u64, reason: &str) -> Result<()> {
128 info!("Declining pull request #{}: {}", pr_id, reason);
129
130 #[derive(Serialize)]
131 struct DeclineRequest {
132 version: u64,
133 #[serde(rename = "participantStatus")]
134 participant_status: String,
135 }
136
137 let pr = self.get_pull_request(pr_id).await?;
139
140 let decline_body = DeclineRequest {
141 version: pr.version,
142 participant_status: "DECLINED".to_string(),
143 };
144
145 let path = format!("pull-requests/{pr_id}/decline");
146
147 let _: serde_json::Value = self.client.post(&path, &decline_body).await?;
149
150 info!("Successfully declined pull request #{}", pr_id);
151 Ok(())
152 }
153
154 pub async fn add_comment(&self, pr_id: u64, comment: &str) -> Result<()> {
156 info!("Adding comment to PR #{}", pr_id);
157
158 #[derive(Serialize)]
159 struct CommentRequest {
160 text: String,
161 }
162
163 let comment_body = CommentRequest {
164 text: comment.to_string(),
165 };
166
167 let path = format!("pull-requests/{pr_id}/comments");
168 let _: serde_json::Value = self.client.post(&path, &comment_body).await?;
169
170 info!("Added comment to PR #{}", pr_id);
171 Ok(())
172 }
173
174 pub async fn get_pull_request_status(&self, pr_id: u64) -> Result<PullRequestStatus> {
176 let pr = self.get_pull_request(pr_id).await?;
178
179 let mergeable_details = self.check_mergeable_detailed(pr_id).await.ok();
181 let mergeable = mergeable_details.as_ref().map(|d| d.can_merge);
182
183 let participants = self.get_pull_request_participants(pr_id).await?;
185 let review_status = self.calculate_review_status(&participants)?;
186
187 let build_status = self.get_build_status(pr_id).await.ok();
189
190 let conflicts = self.get_conflicts(pr_id).await.ok();
192
193 Ok(PullRequestStatus {
194 pr,
195 mergeable,
196 mergeable_details,
197 participants,
198 build_status,
199 review_status,
200 conflicts,
201 })
202 }
203
204 pub async fn get_pull_request_participants(&self, pr_id: u64) -> Result<Vec<Participant>> {
206 let path = format!("pull-requests/{pr_id}/participants");
207 let response: ParticipantsResponse = self.client.get(&path).await?;
208 Ok(response.values)
209 }
210
211 pub async fn check_mergeable_detailed(&self, pr_id: u64) -> Result<MergeabilityDetails> {
213 let path = format!("pull-requests/{pr_id}/merge");
214
215 match self.client.get::<serde_json::Value>(&path).await {
216 Ok(response) => {
217 let can_merge = response
218 .get("canMerge")
219 .and_then(|v| v.as_bool())
220 .unwrap_or(false);
221
222 let conflicted = response
223 .get("conflicted")
224 .and_then(|v| v.as_bool())
225 .unwrap_or(false);
226
227 let mut blocking_reasons = Vec::new();
229
230 if let Some(vetoes) = response.get("vetoes").and_then(|v| v.as_array()) {
231 for veto in vetoes {
232 if let Some(summary) = veto.get("summaryMessage").and_then(|s| s.as_str()) {
233 blocking_reasons.push(summary.to_string());
234 } else if let Some(detailed) =
235 veto.get("detailedMessage").and_then(|s| s.as_str())
236 {
237 blocking_reasons.push(detailed.to_string());
238 }
239 }
240 }
241
242 if conflicted {
244 blocking_reasons.push("Pull request has merge conflicts".to_string());
245 }
246
247 Ok(MergeabilityDetails {
248 can_merge,
249 conflicted,
250 blocking_reasons,
251 server_enforced: true, })
253 }
254 Err(_) => {
255 Ok(MergeabilityDetails {
257 can_merge: true,
258 conflicted: false,
259 blocking_reasons: vec!["Could not verify merge conditions".to_string()],
260 server_enforced: false,
261 })
262 }
263 }
264 }
265
266 pub async fn check_mergeable(&self, pr_id: u64) -> Result<bool> {
268 let details = self.check_mergeable_detailed(pr_id).await?;
269 Ok(details.can_merge)
270 }
271
272 pub async fn get_build_status(&self, pr_id: u64) -> Result<BuildStatus> {
274 let pr = self.get_pull_request(pr_id).await?;
275 let commit_hash = &pr.from_ref.latest_commit;
276
277 let path = format!("commits/{commit_hash}/builds");
279
280 match self.client.get::<BuildStatusResponse>(&path).await {
281 Ok(response) => {
282 if let Some(build) = response.values.first() {
283 Ok(BuildStatus {
284 state: build.state.clone(),
285 url: build.url.clone(),
286 description: build.description.clone(),
287 context: build.name.clone(),
288 })
289 } else {
290 Ok(BuildStatus {
291 state: BuildState::Unknown,
292 url: None,
293 description: Some("No builds found".to_string()),
294 context: None,
295 })
296 }
297 }
298 Err(_) => Ok(BuildStatus {
299 state: BuildState::Unknown,
300 url: None,
301 description: Some("Build status unavailable".to_string()),
302 context: None,
303 }),
304 }
305 }
306
307 pub async fn get_conflicts(&self, pr_id: u64) -> Result<Vec<String>> {
309 let path = format!("pull-requests/{pr_id}/diff");
310
311 match self.client.get::<DiffResponse>(&path).await {
314 Ok(_diff) => {
315 Ok(Vec::new())
318 }
319 Err(_) => Ok(Vec::new()),
320 }
321 }
322
323 fn calculate_review_status(&self, participants: &[Participant]) -> Result<ReviewStatus> {
325 let mut current_approvals = 0;
326 let mut needs_work_count = 0;
327 let mut missing_reviewers = Vec::new();
328
329 for participant in participants {
330 match participant.status {
331 ParticipantStatus::Approved => current_approvals += 1,
332 ParticipantStatus::NeedsWork => needs_work_count += 1,
333 ParticipantStatus::Unapproved => {
334 if matches!(participant.role, ParticipantRole::Reviewer) {
335 missing_reviewers.push(
336 participant
337 .user
338 .display_name
339 .clone()
340 .unwrap_or_else(|| participant.user.name.clone()),
341 );
342 }
343 }
344 }
345 }
346
347 let required_approvals = 1; let can_merge = current_approvals >= required_approvals && needs_work_count == 0;
350
351 Ok(ReviewStatus {
352 required_approvals,
353 current_approvals,
354 needs_work_count,
355 can_merge,
356 missing_reviewers,
357 })
358 }
359
360 pub async fn merge_pull_request(
362 &self,
363 pr_id: u64,
364 merge_strategy: MergeStrategy,
365 ) -> Result<PullRequest> {
366 let pr = self.get_pull_request(pr_id).await?;
367
368 let merge_request = MergePullRequestRequest {
369 version: pr.version,
370 message: merge_strategy.get_commit_message(&pr),
371 strategy: merge_strategy,
372 };
373
374 self.client
375 .post(&format!("pull-requests/{pr_id}/merge"), &merge_request)
376 .await
377 }
378
379 pub async fn auto_merge_if_ready(
381 &self,
382 pr_id: u64,
383 conditions: &AutoMergeConditions,
384 ) -> Result<AutoMergeResult> {
385 let status = self.get_pull_request_status(pr_id).await?;
386
387 if !status.can_auto_merge(conditions) {
388 return Ok(AutoMergeResult::NotReady {
389 blocking_reasons: status.get_blocking_reasons(),
390 });
391 }
392
393 if conditions.wait_for_builds {
395 self.wait_for_builds(pr_id, conditions.build_timeout)
396 .await?;
397 }
398
399 let merged_pr = self
401 .merge_pull_request(pr_id, conditions.merge_strategy.clone())
402 .await?;
403
404 Ok(AutoMergeResult::Merged {
405 pr: Box::new(merged_pr),
406 merge_strategy: conditions.merge_strategy.clone(),
407 })
408 }
409
410 async fn wait_for_builds(&self, pr_id: u64, timeout: Duration) -> Result<()> {
412 use tokio::time::{sleep, timeout as tokio_timeout};
413
414 tokio_timeout(timeout, async {
415 loop {
416 let build_status = self.get_build_status(pr_id).await?;
417
418 match build_status.state {
419 BuildState::Successful => return Ok(()),
420 BuildState::Failed | BuildState::Cancelled => {
421 return Err(CascadeError::bitbucket(format!(
422 "Build failed: {}",
423 build_status.description.unwrap_or_default()
424 )));
425 }
426 BuildState::InProgress => {
427 sleep(Duration::from_secs(30)).await; continue;
429 }
430 BuildState::Unknown => {
431 return Err(CascadeError::bitbucket("Build status unknown".to_string()));
432 }
433 }
434 }
435 })
436 .await
437 .map_err(|_| CascadeError::bitbucket("Build timeout exceeded".to_string()))?
438 }
439}
440
441#[derive(Debug, Serialize, Deserialize, Clone)]
443pub struct CreatePullRequestRequest {
444 pub title: String,
445 pub description: Option<String>,
446 #[serde(rename = "fromRef")]
447 pub from_ref: PullRequestRef,
448 #[serde(rename = "toRef")]
449 pub to_ref: PullRequestRef,
450 #[serde(rename = "isDraft")]
451 pub draft: bool,
452}
453
454#[derive(Debug, Clone, Deserialize, Serialize)]
456pub struct PullRequest {
457 pub id: u64,
458 pub version: u64,
459 pub title: String,
460 pub description: Option<String>,
461 pub state: PullRequestState,
462 pub open: bool,
463 pub closed: bool,
464 #[serde(rename = "createdDate")]
465 pub created_date: u64,
466 #[serde(rename = "updatedDate")]
467 pub updated_date: u64,
468 #[serde(rename = "fromRef")]
469 pub from_ref: PullRequestRef,
470 #[serde(rename = "toRef")]
471 pub to_ref: PullRequestRef,
472 pub locked: bool,
473 pub author: Participant,
474 pub links: PullRequestLinks,
475}
476
477#[derive(Debug, Clone, Deserialize, Serialize)]
479pub struct PullRequestRef {
480 pub id: String,
481 #[serde(rename = "displayId")]
482 pub display_id: String,
483 #[serde(rename = "latestCommit")]
484 pub latest_commit: String,
485 pub repository: Repository,
486}
487
488#[derive(Debug, Clone, Deserialize, Serialize)]
490pub struct Repository {
491 pub id: u64,
492 pub name: String,
493 pub slug: String,
494 #[serde(rename = "scmId")]
495 pub scm_id: String,
496 pub state: String,
497 #[serde(rename = "statusMessage")]
498 pub status_message: Option<String>, pub forkable: bool,
500 pub project: Project,
501 pub public: bool,
502}
503
504#[derive(Debug, Clone, Deserialize, Serialize)]
506pub struct Project {
507 pub id: u64,
508 pub key: String,
509 pub name: String,
510 pub description: Option<String>,
511 pub public: bool,
512 #[serde(rename = "type")]
513 pub project_type: String,
514}
515
516#[derive(Debug, Clone, Deserialize, Serialize)]
518pub struct PullRequestLinks {
519 #[serde(rename = "self")]
520 pub self_link: Vec<SelfLink>,
521}
522
523#[derive(Debug, Clone, Deserialize, Serialize)]
525pub struct SelfLink {
526 pub href: String,
527}
528
529#[derive(Debug, Clone, Deserialize, Serialize)]
531pub struct Participant {
532 pub user: User,
533 pub role: ParticipantRole,
534 pub approved: bool,
535 pub status: ParticipantStatus,
536}
537
538#[derive(Debug, Clone, Deserialize, Serialize)]
540pub struct User {
541 pub name: String,
542 #[serde(rename = "displayName")]
543 pub display_name: Option<String>, #[serde(rename = "emailAddress")]
545 pub email_address: Option<String>, pub active: bool,
547 pub slug: Option<String>, }
549
550#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
552#[serde(rename_all = "UPPERCASE")]
553pub enum PullRequestState {
554 Open,
555 Merged,
556 Declined,
557}
558
559impl PullRequestState {
560 pub fn as_str(&self) -> &'static str {
561 match self {
562 Self::Open => "OPEN",
563 Self::Merged => "MERGED",
564 Self::Declined => "DECLINED",
565 }
566 }
567}
568
569#[derive(Debug, Clone, Deserialize, Serialize)]
571#[serde(rename_all = "UPPERCASE")]
572pub enum ParticipantRole {
573 Author,
574 Reviewer,
575 Participant,
576}
577
578#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
580#[serde(rename_all = "UPPERCASE")]
581pub enum ParticipantStatus {
582 Approved,
583 Unapproved,
584 #[serde(rename = "NEEDS_WORK")]
585 NeedsWork,
586}
587
588#[derive(Debug, Deserialize)]
590pub struct PullRequestPage {
591 pub size: u32,
592 pub limit: u32,
593 #[serde(rename = "isLastPage")]
594 pub is_last_page: bool,
595 pub values: Vec<PullRequest>,
596 pub start: u32,
597 #[serde(rename = "nextPageStart")]
598 pub next_page_start: Option<u32>,
599}
600
601impl PullRequest {
602 pub fn web_url(&self) -> Option<String> {
604 self.links.self_link.first().map(|link| link.href.clone())
605 }
606
607 pub fn is_open(&self) -> bool {
609 self.state == PullRequestState::Open && self.open && !self.closed
610 }
611
612 pub fn created_at(&self) -> DateTime<Utc> {
614 DateTime::from_timestamp(self.created_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
615 }
616
617 pub fn updated_at(&self) -> DateTime<Utc> {
619 DateTime::from_timestamp(self.updated_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
620 }
621}
622
623#[derive(Debug, Clone, Deserialize, Serialize)]
625pub struct PullRequestStatus {
626 pub pr: PullRequest,
627 pub mergeable: Option<bool>,
628 pub mergeable_details: Option<MergeabilityDetails>,
629 pub participants: Vec<Participant>,
630 pub build_status: Option<BuildStatus>,
631 pub review_status: ReviewStatus,
632 pub conflicts: Option<Vec<String>>,
633}
634
635#[derive(Debug, Clone, Deserialize, Serialize)]
637pub struct BuildStatus {
638 pub state: BuildState,
639 pub url: Option<String>,
640 pub description: Option<String>,
641 pub context: Option<String>,
642}
643
644#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
646#[serde(rename_all = "UPPERCASE")]
647pub enum BuildState {
648 Successful,
649 Failed,
650 InProgress,
651 Cancelled,
652 Unknown,
653}
654
655#[derive(Debug, Clone, Deserialize, Serialize)]
657pub struct ReviewStatus {
658 pub required_approvals: usize,
659 pub current_approvals: usize,
660 pub needs_work_count: usize,
661 pub can_merge: bool,
662 pub missing_reviewers: Vec<String>,
663}
664
665#[derive(Debug, Deserialize)]
667struct ParticipantsResponse {
668 pub values: Vec<Participant>,
669}
670
671#[derive(Debug, Deserialize)]
673#[allow(dead_code)]
674struct MergeabilityResponse {
675 #[serde(rename = "canMerge")]
676 pub can_merge: bool,
677 pub conflicted: Option<bool>,
678}
679
680#[derive(Debug, Deserialize)]
682struct BuildStatusResponse {
683 pub values: Vec<BuildInfo>,
684}
685
686#[derive(Debug, Deserialize)]
688struct BuildInfo {
689 pub state: BuildState,
690 pub name: Option<String>,
691 pub url: Option<String>,
692 pub description: Option<String>,
693}
694
695#[derive(Debug, Deserialize)]
697#[allow(dead_code)]
698struct DiffResponse {
699 pub diffs: Vec<serde_json::Value>, }
701
702impl PullRequestStatus {
703 pub fn get_display_status(&self) -> String {
705 if self.pr.state != PullRequestState::Open {
706 return format!("{:?}", self.pr.state).to_uppercase();
707 }
708
709 let mut status_parts = Vec::new();
710
711 if let Some(build) = &self.build_status {
713 match build.state {
714 BuildState::Successful => status_parts.push("✅ Builds"),
715 BuildState::Failed => status_parts.push("❌ Builds"),
716 BuildState::InProgress => status_parts.push("🔄 Building"),
717 _ => status_parts.push("⚪ Builds"),
718 }
719 }
720
721 if self.review_status.can_merge {
723 status_parts.push("✅ Reviews");
724 } else if self.review_status.needs_work_count > 0 {
725 status_parts.push("❌ Reviews");
726 } else {
727 status_parts.push("⏳ Reviews");
728 }
729
730 if let Some(mergeable) = self.mergeable {
732 if mergeable {
733 status_parts.push("✅ Mergeable");
734 } else {
735 status_parts.push("❌ Conflicts");
736 }
737 }
738
739 if status_parts.is_empty() {
740 "🔄 Open".to_string()
741 } else {
742 status_parts.join(" | ")
743 }
744 }
745
746 pub fn is_ready_to_land(&self) -> bool {
748 self.pr.state == PullRequestState::Open
749 && self.review_status.can_merge
750 && self.mergeable.unwrap_or(false)
751 && matches!(
752 self.build_status.as_ref().map(|b| &b.state),
753 Some(BuildState::Successful) | None
754 )
755 }
756
757 pub fn get_blocking_reasons(&self) -> Vec<String> {
759 let mut reasons = Vec::new();
760
761 if let Some(mergeable_details) = &self.mergeable_details {
767 if !mergeable_details.can_merge {
768 for reason in &mergeable_details.blocking_reasons {
770 reasons.push(format!("🔒 Server Check: {reason}"));
771 }
772
773 if mergeable_details.blocking_reasons.is_empty() {
775 reasons.push("🔒 Server Check: Merge blocked by repository policy".to_string());
776 }
777 }
778 } else if self.mergeable == Some(false) {
779 reasons.push("🔒 Server Check: Merge blocked by repository policy".to_string());
781 }
782
783 if !self.pr.is_open() {
785 reasons.push(format!(
786 "❌ PR Status: Pull request is {}",
787 self.pr.state.as_str()
788 ));
789 }
790
791 if let Some(build_status) = &self.build_status {
793 match build_status.state {
794 BuildState::Failed => reasons.push("❌ Build Status: Build failed".to_string()),
795 BuildState::InProgress => {
796 reasons.push("⏳ Build Status: Build in progress".to_string())
797 }
798 BuildState::Cancelled => {
799 reasons.push("❌ Build Status: Build cancelled".to_string())
800 }
801 BuildState::Unknown => {
802 reasons.push("❓ Build Status: Build status unknown".to_string())
803 }
804 BuildState::Successful => {} }
806 }
807
808 if !self.review_status.can_merge {
810 if self.review_status.current_approvals < self.review_status.required_approvals {
811 reasons.push(format!(
812 "👥 Review Status: Need {} more approval{} ({}/{})",
813 self.review_status.required_approvals - self.review_status.current_approvals,
814 if self.review_status.required_approvals - self.review_status.current_approvals
815 == 1
816 {
817 ""
818 } else {
819 "s"
820 },
821 self.review_status.current_approvals,
822 self.review_status.required_approvals
823 ));
824 }
825
826 if self.review_status.needs_work_count > 0 {
827 reasons.push(format!(
828 "👥 Review Status: {} reviewer{} requested changes",
829 self.review_status.needs_work_count,
830 if self.review_status.needs_work_count == 1 {
831 ""
832 } else {
833 "s"
834 }
835 ));
836 }
837
838 if !self.review_status.missing_reviewers.is_empty() {
839 reasons.push(format!(
840 "👥 Review Status: Missing approval from: {}",
841 self.review_status.missing_reviewers.join(", ")
842 ));
843 }
844 }
845
846 if let Some(conflicts) = &self.conflicts {
848 if !conflicts.is_empty() {
849 reasons.push(format!(
850 "⚠️ Merge Conflicts: {} file{} with conflicts",
851 conflicts.len(),
852 if conflicts.len() == 1 { "" } else { "s" }
853 ));
854 }
855 }
856
857 reasons
858 }
859
860 pub fn can_auto_merge(&self, conditions: &AutoMergeConditions) -> bool {
862 if !self.pr.is_open() {
864 return false;
865 }
866
867 if let Some(allowed_authors) = &conditions.allowed_authors {
869 if !allowed_authors.contains(&self.pr.author.user.name) {
870 return false;
871 }
872 }
873
874 self.mergeable.unwrap_or(false)
877 }
878}
879
880#[derive(Debug, Clone)]
882pub struct AutoMergeConditions {
883 pub merge_strategy: MergeStrategy,
884 pub wait_for_builds: bool,
885 pub build_timeout: Duration,
886 pub allowed_authors: Option<Vec<String>>, }
888
889impl Default for AutoMergeConditions {
890 fn default() -> Self {
891 Self {
892 merge_strategy: MergeStrategy::Squash,
893 wait_for_builds: true,
894 build_timeout: Duration::from_secs(1800), allowed_authors: None,
896 }
897 }
898}
899
900#[derive(Debug, Clone, Serialize, Deserialize)]
902#[serde(rename_all = "kebab-case")]
903pub enum MergeStrategy {
904 #[serde(rename = "merge-commit")]
905 Merge,
906 #[serde(rename = "squash")]
907 Squash,
908 #[serde(rename = "fast-forward")]
909 FastForward,
910}
911
912impl MergeStrategy {
913 pub fn get_commit_message(&self, pr: &PullRequest) -> Option<String> {
914 match self {
915 MergeStrategy::Squash => Some(format!(
916 "{}\n\n{}",
917 pr.title,
918 pr.description.as_deref().unwrap_or("")
919 )),
920 _ => None, }
922 }
923}
924
925#[derive(Debug)]
927pub enum AutoMergeResult {
928 Merged {
929 pr: Box<PullRequest>,
930 merge_strategy: MergeStrategy,
931 },
932 NotReady {
933 blocking_reasons: Vec<String>,
934 },
935 Failed {
936 error: String,
937 },
938}
939
940#[derive(Debug, Serialize)]
942struct MergePullRequestRequest {
943 version: u64,
944 #[serde(skip_serializing_if = "Option::is_none")]
945 message: Option<String>,
946 #[serde(rename = "strategy")]
947 strategy: MergeStrategy,
948}
949
950#[derive(Debug, Clone, Deserialize, Serialize)]
952pub struct MergeabilityDetails {
953 pub can_merge: bool,
954 pub conflicted: bool,
955 pub blocking_reasons: Vec<String>,
956 pub server_enforced: bool,
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use std::time::Duration;
963
964 fn create_test_pull_request(id: u64, state: PullRequestState) -> PullRequest {
966 let is_open = state == PullRequestState::Open;
967 PullRequest {
968 id,
969 version: 1,
970 title: "Test PR".to_string(),
971 description: Some("Test description".to_string()),
972 state: state.clone(),
973 open: is_open,
974 closed: !is_open,
975 created_date: 1700000000000, updated_date: 1700000000000,
977 from_ref: PullRequestRef {
978 id: "refs/heads/feature".to_string(),
979 display_id: "feature".to_string(),
980 latest_commit: "abc123".to_string(),
981 repository: create_test_repository(),
982 },
983 to_ref: PullRequestRef {
984 id: "refs/heads/main".to_string(),
985 display_id: "main".to_string(),
986 latest_commit: "def456".to_string(),
987 repository: create_test_repository(),
988 },
989 locked: false,
990 author: create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved),
991 links: PullRequestLinks {
992 self_link: vec![SelfLink {
993 href: format!(
994 "http://bitbucket.local/projects/TEST/repos/test/pull-requests/{id}"
995 ),
996 }],
997 },
998 }
999 }
1000
1001 fn create_test_repository() -> Repository {
1002 Repository {
1003 id: 1,
1004 name: "test-repo".to_string(),
1005 slug: "test-repo".to_string(),
1006 scm_id: "git".to_string(),
1007 state: "AVAILABLE".to_string(),
1008 status_message: Some("Available".to_string()),
1009 forkable: true,
1010 project: Project {
1011 id: 1,
1012 key: "TEST".to_string(),
1013 name: "Test Project".to_string(),
1014 description: Some("Test project description".to_string()),
1015 public: false,
1016 project_type: "NORMAL".to_string(),
1017 },
1018 public: false,
1019 }
1020 }
1021
1022 fn create_test_participant(role: ParticipantRole, status: ParticipantStatus) -> Participant {
1023 Participant {
1024 user: User {
1025 name: "testuser".to_string(),
1026 display_name: Some("Test User".to_string()),
1027 email_address: Some("test@example.com".to_string()),
1028 active: true,
1029 slug: Some("testuser".to_string()),
1030 },
1031 role,
1032 approved: status == ParticipantStatus::Approved,
1033 status,
1034 }
1035 }
1036
1037 fn create_test_build_status(state: BuildState) -> BuildStatus {
1038 BuildStatus {
1039 state,
1040 url: Some("http://ci.example.com/build/123".to_string()),
1041 description: Some("Test build".to_string()),
1042 context: Some("CI/CD".to_string()),
1043 }
1044 }
1045
1046 #[test]
1047 fn test_pull_request_state_serialization() {
1048 assert_eq!(PullRequestState::Open.as_str(), "OPEN");
1049 assert_eq!(PullRequestState::Merged.as_str(), "MERGED");
1050 assert_eq!(PullRequestState::Declined.as_str(), "DECLINED");
1051 }
1052
1053 #[test]
1054 fn test_pull_request_is_open() {
1055 let open_pr = create_test_pull_request(1, PullRequestState::Open);
1056 assert!(open_pr.is_open());
1057
1058 let merged_pr = create_test_pull_request(2, PullRequestState::Merged);
1059 assert!(!merged_pr.is_open());
1060
1061 let declined_pr = create_test_pull_request(3, PullRequestState::Declined);
1062 assert!(!declined_pr.is_open());
1063 }
1064
1065 #[test]
1066 fn test_pull_request_web_url() {
1067 let pr = create_test_pull_request(123, PullRequestState::Open);
1068 let url = pr.web_url();
1069 assert!(url.is_some());
1070 assert_eq!(
1071 url.unwrap(),
1072 "http://bitbucket.local/projects/TEST/repos/test/pull-requests/123"
1073 );
1074 }
1075
1076 #[test]
1077 fn test_merge_strategy_conversion() {
1078 let squash = MergeStrategy::Squash;
1079 let merge = MergeStrategy::Merge;
1080 let ff = MergeStrategy::FastForward;
1081
1082 assert!(matches!(squash, MergeStrategy::Squash));
1084 assert!(matches!(merge, MergeStrategy::Merge));
1085 assert!(matches!(ff, MergeStrategy::FastForward));
1086 }
1087
1088 #[test]
1089 fn test_merge_strategy_commit_message() {
1090 let pr = create_test_pull_request(1, PullRequestState::Open);
1091
1092 let squash_strategy = MergeStrategy::Squash;
1093 let message = squash_strategy.get_commit_message(&pr);
1094 assert!(message.is_some());
1095 assert!(message.unwrap().contains("Test PR"));
1096
1097 let merge_strategy = MergeStrategy::Merge;
1098 let message = merge_strategy.get_commit_message(&pr);
1099 assert!(message.is_none()); let ff_strategy = MergeStrategy::FastForward;
1102 let message = ff_strategy.get_commit_message(&pr);
1103 assert!(message.is_none()); }
1105
1106 #[test]
1107 fn test_auto_merge_conditions_default() {
1108 let conditions = AutoMergeConditions::default();
1109
1110 assert!(conditions.wait_for_builds); assert_eq!(conditions.build_timeout.as_secs(), 1800); assert!(conditions.allowed_authors.is_none());
1113 assert!(matches!(conditions.merge_strategy, MergeStrategy::Squash));
1114 }
1115
1116 #[test]
1117 fn test_auto_merge_conditions_custom() {
1118 let conditions = AutoMergeConditions {
1119 merge_strategy: MergeStrategy::Merge,
1120 wait_for_builds: false,
1121 build_timeout: Duration::from_secs(3600),
1122 allowed_authors: Some(vec!["trusted-user".to_string()]),
1123 };
1124
1125 assert!(matches!(conditions.merge_strategy, MergeStrategy::Merge));
1126 assert!(!conditions.wait_for_builds);
1127 assert_eq!(conditions.build_timeout.as_secs(), 3600);
1128 assert!(conditions.allowed_authors.is_some());
1129 }
1130
1131 #[test]
1132 fn test_pull_request_status_ready_to_land() {
1133 let pr = create_test_pull_request(1, PullRequestState::Open);
1134 let participants = vec![create_test_participant(
1135 ParticipantRole::Reviewer,
1136 ParticipantStatus::Approved,
1137 )];
1138 let review_status = ReviewStatus {
1139 required_approvals: 1,
1140 current_approvals: 1,
1141 needs_work_count: 0,
1142 can_merge: true,
1143 missing_reviewers: vec![],
1144 };
1145
1146 let status = PullRequestStatus {
1147 pr,
1148 mergeable: Some(true),
1149 mergeable_details: None,
1150 participants,
1151 build_status: Some(create_test_build_status(BuildState::Successful)),
1152 review_status,
1153 conflicts: None,
1154 };
1155
1156 assert!(status.is_ready_to_land());
1157 }
1158
1159 #[test]
1160 fn test_pull_request_status_not_ready_to_land() {
1161 let pr = create_test_pull_request(1, PullRequestState::Open);
1162 let participants = vec![create_test_participant(
1163 ParticipantRole::Reviewer,
1164 ParticipantStatus::Unapproved,
1165 )];
1166 let review_status = ReviewStatus {
1167 required_approvals: 1,
1168 current_approvals: 0,
1169 needs_work_count: 0,
1170 can_merge: false,
1171 missing_reviewers: vec!["reviewer".to_string()],
1172 };
1173
1174 let status = PullRequestStatus {
1175 pr,
1176 mergeable: Some(false),
1177 mergeable_details: None,
1178 participants,
1179 build_status: Some(create_test_build_status(BuildState::Failed)),
1180 review_status,
1181 conflicts: Some(vec!["Conflict in file.txt".to_string()]),
1182 };
1183
1184 assert!(!status.is_ready_to_land());
1185 }
1186
1187 #[test]
1188 fn test_pull_request_status_blocking_reasons() {
1189 let pr_status = PullRequestStatus {
1191 pr: create_test_pull_request(1, PullRequestState::Open),
1192 mergeable: Some(true),
1193 mergeable_details: None,
1194 participants: vec![create_test_participant(
1195 ParticipantRole::Author,
1196 ParticipantStatus::Approved,
1197 )],
1198 build_status: Some(create_test_build_status(BuildState::Failed)),
1199 review_status: ReviewStatus {
1200 required_approvals: 1,
1201 current_approvals: 0, needs_work_count: 0,
1203 can_merge: false,
1204 missing_reviewers: vec!["reviewer1".to_string()],
1205 },
1206 conflicts: None,
1207 };
1208
1209 let blocking_reasons = pr_status.get_blocking_reasons();
1210
1211 assert!(!blocking_reasons.is_empty());
1213
1214 assert!(blocking_reasons.iter().any(|r| r.contains("Build failed")));
1216
1217 assert!(blocking_reasons.iter().any(|r| r.contains("more approval")));
1219 }
1220
1221 #[test]
1222 fn test_pull_request_status_can_auto_merge() {
1223 let pr = create_test_pull_request(1, PullRequestState::Open);
1224 let participants = vec![create_test_participant(
1225 ParticipantRole::Reviewer,
1226 ParticipantStatus::Approved,
1227 )];
1228 let review_status = ReviewStatus {
1229 required_approvals: 1,
1230 current_approvals: 1,
1231 needs_work_count: 0,
1232 can_merge: true,
1233 missing_reviewers: vec![],
1234 };
1235
1236 let status = PullRequestStatus {
1237 pr,
1238 mergeable: Some(true),
1239 mergeable_details: None,
1240 participants,
1241 build_status: Some(create_test_build_status(BuildState::Successful)),
1242 review_status,
1243 conflicts: None,
1244 };
1245
1246 let conditions = AutoMergeConditions::default();
1247 assert!(status.can_auto_merge(&conditions));
1248
1249 let allowlist_conditions = AutoMergeConditions {
1251 allowed_authors: Some(vec!["testuser".to_string()]),
1252 ..Default::default()
1253 };
1254 assert!(status.can_auto_merge(&allowlist_conditions));
1255
1256 let mut status_not_mergeable = status.clone();
1258 status_not_mergeable.mergeable = Some(false);
1259 assert!(!status_not_mergeable.can_auto_merge(&conditions));
1260 }
1261
1262 #[test]
1263 fn test_build_state_variants() {
1264 let _successful = BuildState::Successful;
1266 let _failed = BuildState::Failed;
1267 let _in_progress = BuildState::InProgress;
1268 let _cancelled = BuildState::Cancelled;
1269 let _unknown = BuildState::Unknown;
1270
1271 }
1274
1275 #[test]
1276 fn test_review_status_calculations() {
1277 let review_status = ReviewStatus {
1278 required_approvals: 2,
1279 current_approvals: 1,
1280 needs_work_count: 0,
1281 can_merge: false,
1282 missing_reviewers: vec!["reviewer2".to_string()],
1283 };
1284
1285 assert_eq!(review_status.required_approvals, 2);
1286 assert_eq!(review_status.current_approvals, 1);
1287 assert_eq!(review_status.needs_work_count, 0);
1288 assert!(!review_status.can_merge);
1289 assert_eq!(review_status.missing_reviewers.len(), 1);
1290 }
1291
1292 #[test]
1293 fn test_auto_merge_result_variants() {
1294 let pr = create_test_pull_request(1, PullRequestState::Merged);
1295
1296 let merged_result = AutoMergeResult::Merged {
1298 pr: Box::new(pr.clone()),
1299 merge_strategy: MergeStrategy::Squash,
1300 };
1301 assert!(matches!(merged_result, AutoMergeResult::Merged { .. }));
1302
1303 let not_ready_result = AutoMergeResult::NotReady {
1305 blocking_reasons: vec!["Missing approvals".to_string()],
1306 };
1307 assert!(matches!(not_ready_result, AutoMergeResult::NotReady { .. }));
1308
1309 let failed_result = AutoMergeResult::Failed {
1311 error: "Network error".to_string(),
1312 };
1313 assert!(matches!(failed_result, AutoMergeResult::Failed { .. }));
1314 }
1315
1316 #[test]
1317 fn test_participant_roles_and_status() {
1318 let author = create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved);
1319 assert!(matches!(author.role, ParticipantRole::Author));
1320 assert!(author.approved);
1321
1322 let reviewer =
1323 create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::Unapproved);
1324 assert!(matches!(reviewer.role, ParticipantRole::Reviewer));
1325 assert!(!reviewer.approved);
1326
1327 let needs_work =
1328 create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::NeedsWork);
1329 assert!(matches!(needs_work.status, ParticipantStatus::NeedsWork));
1330 assert!(!needs_work.approved);
1331 }
1332
1333 #[test]
1334 fn test_polling_frequency_constant() {
1335 use std::time::Duration;
1337
1338 let polling_interval = Duration::from_secs(30);
1339 assert_eq!(polling_interval.as_secs(), 30);
1340
1341 assert!(polling_interval.as_secs() >= 10);
1343 assert!(polling_interval.as_secs() <= 60);
1344 }
1345}