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 debug!("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 debug!("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 match self
278 .client
279 .get_build_statuses::<BuildStatusResponse>(commit_hash)
280 .await
281 {
282 Ok(response) => {
283 if response.values.is_empty() {
284 Ok(BuildStatus {
285 state: BuildState::Unknown,
286 url: None,
287 description: Some("No builds found".to_string()),
288 context: None,
289 })
290 } else {
291 let mut aggregated_state = BuildState::Unknown;
292
293 for build in &response.values {
294 match build.state {
295 BuildState::Failed => {
296 aggregated_state = BuildState::Failed;
297 break;
298 }
299 BuildState::InProgress => {
300 if !matches!(aggregated_state, BuildState::Failed) {
301 aggregated_state = BuildState::InProgress;
302 }
303 }
304 BuildState::Successful => {
305 if matches!(
306 aggregated_state,
307 BuildState::Unknown | BuildState::Cancelled
308 ) {
309 aggregated_state = BuildState::Successful;
310 }
311 }
312 BuildState::Cancelled => {
313 if matches!(aggregated_state, BuildState::Unknown) {
314 aggregated_state = BuildState::Cancelled;
315 }
316 }
317 BuildState::Unknown => {}
318 }
319 }
320
321 let representative = response.values.first().unwrap();
322
323 Ok(BuildStatus {
324 state: aggregated_state,
325 url: representative.url.clone(),
326 description: representative.description.clone(),
327 context: representative.name.clone(),
328 })
329 }
330 }
331 Err(_) => Ok(BuildStatus {
332 state: BuildState::Unknown,
333 url: None,
334 description: Some("Build status unavailable".to_string()),
335 context: None,
336 }),
337 }
338 }
339
340 pub async fn get_conflicts(&self, pr_id: u64) -> Result<Vec<String>> {
347 let _ = pr_id; Ok(Vec::new())
352 }
353
354 fn calculate_review_status(&self, participants: &[Participant]) -> Result<ReviewStatus> {
356 let mut current_approvals = 0;
357 let mut needs_work_count = 0;
358 let mut missing_reviewers = Vec::new();
359
360 for participant in participants {
361 match participant.status {
362 ParticipantStatus::Approved => current_approvals += 1,
363 ParticipantStatus::NeedsWork => needs_work_count += 1,
364 ParticipantStatus::Unapproved => {
365 if matches!(participant.role, ParticipantRole::Reviewer) {
366 missing_reviewers.push(
367 participant
368 .user
369 .display_name
370 .clone()
371 .unwrap_or_else(|| participant.user.name.clone()),
372 );
373 }
374 }
375 }
376 }
377
378 let can_merge = current_approvals > 0 && needs_work_count == 0;
390
391 Ok(ReviewStatus {
392 required_approvals: 0, current_approvals,
394 needs_work_count,
395 can_merge,
396 missing_reviewers,
397 })
398 }
399
400 pub async fn merge_pull_request(
402 &self,
403 pr_id: u64,
404 merge_strategy: MergeStrategy,
405 ) -> Result<PullRequest> {
406 let pr = self.get_pull_request(pr_id).await?;
407
408 let merge_request = MergePullRequestRequest {
409 version: pr.version,
410 message: merge_strategy.get_commit_message(&pr),
411 strategy: merge_strategy,
412 };
413
414 self.client
415 .post(&format!("pull-requests/{pr_id}/merge"), &merge_request)
416 .await
417 }
418
419 pub async fn auto_merge_if_ready(
421 &self,
422 pr_id: u64,
423 conditions: &AutoMergeConditions,
424 ) -> Result<AutoMergeResult> {
425 let status = self.get_pull_request_status(pr_id).await?;
426
427 if !status.can_auto_merge(conditions) {
428 return Ok(AutoMergeResult::NotReady {
429 blocking_reasons: status.get_blocking_reasons(),
430 });
431 }
432
433 if conditions.wait_for_builds {
435 self.wait_for_builds(pr_id, conditions.build_timeout)
436 .await?;
437 }
438
439 let merged_pr = self
441 .merge_pull_request(pr_id, conditions.merge_strategy.clone())
442 .await?;
443
444 Ok(AutoMergeResult::Merged {
445 pr: Box::new(merged_pr),
446 merge_strategy: conditions.merge_strategy.clone(),
447 })
448 }
449
450 async fn wait_for_builds(&self, pr_id: u64, timeout: Duration) -> Result<()> {
452 use tokio::time::{sleep, timeout as tokio_timeout};
453
454 tokio_timeout(timeout, async {
455 loop {
456 let build_status = self.get_build_status(pr_id).await?;
457
458 match build_status.state {
459 BuildState::Successful => return Ok(()),
460 BuildState::Failed | BuildState::Cancelled => {
461 return Err(CascadeError::bitbucket(format!(
462 "Build failed: {}",
463 build_status.description.unwrap_or_default()
464 )));
465 }
466 BuildState::InProgress => {
467 sleep(Duration::from_secs(30)).await; continue;
469 }
470 BuildState::Unknown => {
471 return Err(CascadeError::bitbucket("Build status unknown".to_string()));
472 }
473 }
474 }
475 })
476 .await
477 .map_err(|_| CascadeError::bitbucket("Build timeout exceeded".to_string()))?
478 }
479}
480
481#[derive(Debug, Serialize, Deserialize, Clone)]
483pub struct CreatePullRequestRequest {
484 pub title: String,
485 pub description: Option<String>,
486 #[serde(rename = "fromRef")]
487 pub from_ref: PullRequestRef,
488 #[serde(rename = "toRef")]
489 pub to_ref: PullRequestRef,
490 #[serde(rename = "isDraft")]
491 pub draft: bool,
492}
493
494#[derive(Debug, Clone, Deserialize, Serialize)]
496pub struct PullRequest {
497 pub id: u64,
498 pub version: u64,
499 pub title: String,
500 pub description: Option<String>,
501 pub state: PullRequestState,
502 pub open: bool,
503 pub closed: bool,
504 #[serde(rename = "createdDate")]
505 pub created_date: u64,
506 #[serde(rename = "updatedDate")]
507 pub updated_date: u64,
508 #[serde(rename = "fromRef")]
509 pub from_ref: PullRequestRef,
510 #[serde(rename = "toRef")]
511 pub to_ref: PullRequestRef,
512 pub locked: bool,
513 pub author: Participant,
514 pub links: PullRequestLinks,
515}
516
517#[derive(Debug, Clone, Deserialize, Serialize)]
519pub struct PullRequestRef {
520 pub id: String,
521 #[serde(rename = "displayId")]
522 pub display_id: String,
523 #[serde(rename = "latestCommit")]
524 pub latest_commit: String,
525 pub repository: Repository,
526}
527
528#[derive(Debug, Clone, Deserialize, Serialize)]
530pub struct Repository {
531 pub id: u64,
532 pub name: String,
533 pub slug: String,
534 #[serde(rename = "scmId")]
535 pub scm_id: String,
536 pub state: String,
537 #[serde(rename = "statusMessage")]
538 pub status_message: Option<String>, pub forkable: bool,
540 pub project: Project,
541 pub public: bool,
542}
543
544#[derive(Debug, Clone, Deserialize, Serialize)]
546pub struct Project {
547 pub id: u64,
548 pub key: String,
549 pub name: String,
550 pub description: Option<String>,
551 pub public: bool,
552 #[serde(rename = "type")]
553 pub project_type: String,
554}
555
556#[derive(Debug, Clone, Deserialize, Serialize)]
558pub struct PullRequestLinks {
559 #[serde(rename = "self")]
560 pub self_link: Vec<SelfLink>,
561}
562
563#[derive(Debug, Clone, Deserialize, Serialize)]
565pub struct SelfLink {
566 pub href: String,
567}
568
569#[derive(Debug, Clone, Deserialize, Serialize)]
571pub struct Participant {
572 pub user: User,
573 pub role: ParticipantRole,
574 pub approved: bool,
575 pub status: ParticipantStatus,
576}
577
578#[derive(Debug, Clone, Deserialize, Serialize)]
580pub struct User {
581 pub name: String,
582 #[serde(rename = "displayName")]
583 pub display_name: Option<String>, #[serde(rename = "emailAddress")]
585 pub email_address: Option<String>, pub active: bool,
587 pub slug: Option<String>, }
589
590#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
592#[serde(rename_all = "UPPERCASE")]
593pub enum PullRequestState {
594 Open,
595 Merged,
596 Declined,
597}
598
599impl PullRequestState {
600 pub fn as_str(&self) -> &'static str {
601 match self {
602 Self::Open => "OPEN",
603 Self::Merged => "MERGED",
604 Self::Declined => "DECLINED",
605 }
606 }
607}
608
609#[derive(Debug, Clone, Deserialize, Serialize)]
611#[serde(rename_all = "UPPERCASE")]
612pub enum ParticipantRole {
613 Author,
614 Reviewer,
615 Participant,
616}
617
618#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
620#[serde(rename_all = "UPPERCASE")]
621pub enum ParticipantStatus {
622 Approved,
623 Unapproved,
624 #[serde(rename = "NEEDS_WORK")]
625 NeedsWork,
626}
627
628#[derive(Debug, Deserialize)]
630pub struct PullRequestPage {
631 pub size: u32,
632 pub limit: u32,
633 #[serde(rename = "isLastPage")]
634 pub is_last_page: bool,
635 pub values: Vec<PullRequest>,
636 pub start: u32,
637 #[serde(rename = "nextPageStart")]
638 pub next_page_start: Option<u32>,
639}
640
641impl PullRequest {
642 pub fn web_url(&self) -> Option<String> {
644 self.links.self_link.first().map(|link| link.href.clone())
645 }
646
647 pub fn is_open(&self) -> bool {
649 self.state == PullRequestState::Open && self.open && !self.closed
650 }
651
652 pub fn created_at(&self) -> DateTime<Utc> {
654 DateTime::from_timestamp(self.created_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
655 }
656
657 pub fn updated_at(&self) -> DateTime<Utc> {
659 DateTime::from_timestamp(self.updated_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
660 }
661}
662
663#[derive(Debug, Clone, Deserialize, Serialize)]
665pub struct PullRequestStatus {
666 pub pr: PullRequest,
667 pub mergeable: Option<bool>,
668 pub mergeable_details: Option<MergeabilityDetails>,
669 pub participants: Vec<Participant>,
670 pub build_status: Option<BuildStatus>,
671 pub review_status: ReviewStatus,
672 pub conflicts: Option<Vec<String>>,
673}
674
675#[derive(Debug, Clone, Deserialize, Serialize)]
677pub struct BuildStatus {
678 pub state: BuildState,
679 pub url: Option<String>,
680 pub description: Option<String>,
681 pub context: Option<String>,
682}
683
684#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
686#[serde(rename_all = "UPPERCASE")]
687pub enum BuildState {
688 Successful,
689 Failed,
690 InProgress,
691 Cancelled,
692 Unknown,
693}
694
695#[derive(Debug, Clone, Deserialize, Serialize)]
697pub struct ReviewStatus {
698 pub required_approvals: usize,
699 pub current_approvals: usize,
700 pub needs_work_count: usize,
701 pub can_merge: bool,
702 pub missing_reviewers: Vec<String>,
703}
704
705#[derive(Debug, Deserialize)]
707struct ParticipantsResponse {
708 pub values: Vec<Participant>,
709}
710
711#[derive(Debug, Deserialize)]
713#[allow(dead_code)]
714struct MergeabilityResponse {
715 #[serde(rename = "canMerge")]
716 pub can_merge: bool,
717 pub conflicted: Option<bool>,
718}
719
720#[derive(Debug, Deserialize)]
722struct BuildStatusResponse {
723 pub values: Vec<BuildInfo>,
724}
725
726#[derive(Debug, Deserialize)]
728struct BuildInfo {
729 pub state: BuildState,
730 pub name: Option<String>,
731 pub url: Option<String>,
732 pub description: Option<String>,
733}
734
735#[derive(Debug, Deserialize)]
737#[allow(dead_code)]
738struct DiffResponse {
739 pub diffs: Vec<serde_json::Value>, }
741
742impl PullRequestStatus {
743 pub fn apply_advisory_filters(&mut self, advisory_patterns: &[String]) {
747 if advisory_patterns.is_empty() {
748 return;
749 }
750 if let Some(details) = &mut self.mergeable_details {
751 if details.can_merge {
752 return; }
754 details.blocking_reasons.retain(|reason| {
755 let reason_lower = reason.to_lowercase();
756 !advisory_patterns
757 .iter()
758 .any(|pattern| reason_lower.contains(&pattern.to_lowercase()))
759 });
760 if details.blocking_reasons.is_empty() && !details.conflicted {
762 details.can_merge = true;
763 self.mergeable = Some(true);
764 }
765 }
766 }
767
768 pub fn get_display_status(&self) -> String {
770 if self.pr.state != PullRequestState::Open {
771 return format!("{:?}", self.pr.state).to_uppercase();
772 }
773
774 let mut status_parts = Vec::new();
775
776 if let Some(build) = &self.build_status {
778 let build_text = match build.state {
779 BuildState::Successful => "Builds: Passing",
780 BuildState::Failed => "Builds: Failing",
781 BuildState::InProgress => "Builds: Running",
782 BuildState::Cancelled => "Builds: Cancelled",
783 BuildState::Unknown => "Builds: Unknown",
784 };
785 status_parts.push(build_text.to_string());
786 } else {
787 status_parts.push("Builds: Unknown".to_string());
788 }
789
790 let review_text = if self.review_status.can_merge {
792 "Reviews: Approved".to_string()
793 } else if self.review_status.needs_work_count > 0 {
794 "Reviews: Changes Requested".to_string()
795 } else if self.review_status.current_approvals > 0
796 && self.review_status.required_approvals > 0
797 {
798 format!(
799 "Reviews: {}/{} approvals",
800 self.review_status.current_approvals, self.review_status.required_approvals
801 )
802 } else {
803 "Reviews: Pending".to_string()
804 };
805 status_parts.push(review_text);
806
807 let merge_text = if let Some(details) = &self.mergeable_details {
809 if details.can_merge {
810 "Merge: Ready".to_string()
811 } else if !details.blocking_reasons.is_empty() {
812 format!("Merge: Blocked ({})", details.blocking_reasons[0])
813 } else if details.conflicted {
814 "Merge: Blocked (Conflicts)".to_string()
815 } else {
816 "Merge: Blocked".to_string()
817 }
818 } else {
819 match self.mergeable {
820 Some(true) => "Merge: Ready".to_string(),
821 Some(false) => "Merge: Blocked".to_string(),
822 None => "Merge: Unknown".to_string(),
823 }
824 };
825 status_parts.push(merge_text);
826
827 if status_parts.is_empty() {
828 "Open".to_string()
829 } else {
830 status_parts.join(" | ")
831 }
832 }
833
834 pub fn is_ready_to_land(&self) -> bool {
836 self.pr.state == PullRequestState::Open
837 && self.review_status.can_merge
838 && self.mergeable.unwrap_or(false)
839 && matches!(
840 self.build_status.as_ref().map(|b| &b.state),
841 Some(BuildState::Successful) | None
842 )
843 }
844
845 pub fn get_blocking_reasons(&self) -> Vec<String> {
847 let mut reasons = Vec::new();
848
849 if let Some(mergeable_details) = &self.mergeable_details {
855 if !mergeable_details.can_merge {
856 for reason in &mergeable_details.blocking_reasons {
858 reasons.push(format!("Server Check: {reason}"));
859 }
860
861 if mergeable_details.blocking_reasons.is_empty() {
863 reasons.push("Server Check: Merge blocked by repository policy".to_string());
864 }
865 }
866 } else if self.mergeable == Some(false) {
867 reasons.push("Server Check: Merge blocked by repository policy".to_string());
869 }
870
871 if !self.pr.is_open() {
873 reasons.push(format!(
874 "PR Status: Pull request is {}",
875 self.pr.state.as_str()
876 ));
877 }
878
879 if let Some(build_status) = &self.build_status {
881 match build_status.state {
882 BuildState::Failed => reasons.push("Build Status: Build failed".to_string()),
883 BuildState::InProgress => {
884 reasons.push("Build Status: Build in progress".to_string())
885 }
886 BuildState::Cancelled => reasons.push("Build Status: Build cancelled".to_string()),
887 BuildState::Unknown => {
888 reasons.push("Build Status: Build status unknown".to_string())
889 }
890 BuildState::Successful => {} }
892 }
893
894 if !self.review_status.can_merge {
896 if self.review_status.current_approvals == 0 {
899 reasons.push("Review Status: No approvals yet".to_string());
900 }
901
902 if self.review_status.needs_work_count > 0 {
903 reasons.push(format!(
904 "Review Status: {} reviewer{} requested changes",
905 self.review_status.needs_work_count,
906 if self.review_status.needs_work_count == 1 {
907 ""
908 } else {
909 "s"
910 }
911 ));
912 }
913
914 if !self.review_status.missing_reviewers.is_empty() {
915 reasons.push(format!(
916 "Review Status: Missing approval from: {}",
917 self.review_status.missing_reviewers.join(", ")
918 ));
919 }
920 }
921
922 if let Some(conflicts) = &self.conflicts {
924 if !conflicts.is_empty() {
925 reasons.push(format!(
926 "⚠️ Merge Conflicts: {} file{} with conflicts",
927 conflicts.len(),
928 if conflicts.len() == 1 { "" } else { "s" }
929 ));
930 }
931 }
932
933 reasons
934 }
935
936 pub fn can_auto_merge(&self, conditions: &AutoMergeConditions) -> bool {
938 if !self.pr.is_open() {
940 return false;
941 }
942
943 if let Some(allowed_authors) = &conditions.allowed_authors {
945 if !allowed_authors.contains(&self.pr.author.user.name) {
946 return false;
947 }
948 }
949
950 self.mergeable.unwrap_or(false)
953 }
954}
955
956#[derive(Debug, Clone)]
958pub struct AutoMergeConditions {
959 pub merge_strategy: MergeStrategy,
960 pub wait_for_builds: bool,
961 pub build_timeout: Duration,
962 pub allowed_authors: Option<Vec<String>>, }
964
965impl Default for AutoMergeConditions {
966 fn default() -> Self {
967 Self {
968 merge_strategy: MergeStrategy::Squash,
969 wait_for_builds: true,
970 build_timeout: Duration::from_secs(1800), allowed_authors: None,
972 }
973 }
974}
975
976#[derive(Debug, Clone, Serialize, Deserialize)]
978#[serde(rename_all = "kebab-case")]
979pub enum MergeStrategy {
980 #[serde(rename = "merge-commit")]
981 Merge,
982 #[serde(rename = "squash")]
983 Squash,
984 #[serde(rename = "fast-forward")]
985 FastForward,
986}
987
988impl MergeStrategy {
989 pub fn get_commit_message(&self, pr: &PullRequest) -> Option<String> {
990 match self {
991 MergeStrategy::Squash => Some(format!(
992 "{}\n\n{}",
993 pr.title,
994 pr.description.as_deref().unwrap_or("")
995 )),
996 _ => None, }
998 }
999}
1000
1001#[derive(Debug)]
1003pub enum AutoMergeResult {
1004 Merged {
1005 pr: Box<PullRequest>,
1006 merge_strategy: MergeStrategy,
1007 },
1008 NotReady {
1009 blocking_reasons: Vec<String>,
1010 },
1011 Failed {
1012 error: String,
1013 },
1014}
1015
1016#[derive(Debug, Serialize)]
1018struct MergePullRequestRequest {
1019 version: u64,
1020 #[serde(skip_serializing_if = "Option::is_none")]
1021 message: Option<String>,
1022 #[serde(rename = "strategy")]
1023 strategy: MergeStrategy,
1024}
1025
1026#[derive(Debug, Clone, Deserialize, Serialize)]
1028pub struct MergeabilityDetails {
1029 pub can_merge: bool,
1030 pub conflicted: bool,
1031 pub blocking_reasons: Vec<String>,
1032 pub server_enforced: bool,
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038 use std::time::Duration;
1039
1040 fn create_test_pull_request(id: u64, state: PullRequestState) -> PullRequest {
1042 let is_open = state == PullRequestState::Open;
1043 PullRequest {
1044 id,
1045 version: 1,
1046 title: "Test PR".to_string(),
1047 description: Some("Test description".to_string()),
1048 state: state.clone(),
1049 open: is_open,
1050 closed: !is_open,
1051 created_date: 1700000000000, updated_date: 1700000000000,
1053 from_ref: PullRequestRef {
1054 id: "refs/heads/feature".to_string(),
1055 display_id: "feature".to_string(),
1056 latest_commit: "abc123".to_string(),
1057 repository: create_test_repository(),
1058 },
1059 to_ref: PullRequestRef {
1060 id: "refs/heads/main".to_string(),
1061 display_id: "main".to_string(),
1062 latest_commit: "def456".to_string(),
1063 repository: create_test_repository(),
1064 },
1065 locked: false,
1066 author: create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved),
1067 links: PullRequestLinks {
1068 self_link: vec![SelfLink {
1069 href: format!(
1070 "http://bitbucket.local/projects/TEST/repos/test/pull-requests/{id}"
1071 ),
1072 }],
1073 },
1074 }
1075 }
1076
1077 fn create_test_repository() -> Repository {
1078 Repository {
1079 id: 1,
1080 name: "test-repo".to_string(),
1081 slug: "test-repo".to_string(),
1082 scm_id: "git".to_string(),
1083 state: "AVAILABLE".to_string(),
1084 status_message: Some("Available".to_string()),
1085 forkable: true,
1086 project: Project {
1087 id: 1,
1088 key: "TEST".to_string(),
1089 name: "Test Project".to_string(),
1090 description: Some("Test project description".to_string()),
1091 public: false,
1092 project_type: "NORMAL".to_string(),
1093 },
1094 public: false,
1095 }
1096 }
1097
1098 fn create_test_participant(role: ParticipantRole, status: ParticipantStatus) -> Participant {
1099 Participant {
1100 user: User {
1101 name: "testuser".to_string(),
1102 display_name: Some("Test User".to_string()),
1103 email_address: Some("test@example.com".to_string()),
1104 active: true,
1105 slug: Some("testuser".to_string()),
1106 },
1107 role,
1108 approved: status == ParticipantStatus::Approved,
1109 status,
1110 }
1111 }
1112
1113 fn create_test_build_status(state: BuildState) -> BuildStatus {
1114 BuildStatus {
1115 state,
1116 url: Some("http://ci.example.com/build/123".to_string()),
1117 description: Some("Test build".to_string()),
1118 context: Some("CI/CD".to_string()),
1119 }
1120 }
1121
1122 #[test]
1123 fn test_pull_request_state_serialization() {
1124 assert_eq!(PullRequestState::Open.as_str(), "OPEN");
1125 assert_eq!(PullRequestState::Merged.as_str(), "MERGED");
1126 assert_eq!(PullRequestState::Declined.as_str(), "DECLINED");
1127 }
1128
1129 #[test]
1130 fn test_pull_request_is_open() {
1131 let open_pr = create_test_pull_request(1, PullRequestState::Open);
1132 assert!(open_pr.is_open());
1133
1134 let merged_pr = create_test_pull_request(2, PullRequestState::Merged);
1135 assert!(!merged_pr.is_open());
1136
1137 let declined_pr = create_test_pull_request(3, PullRequestState::Declined);
1138 assert!(!declined_pr.is_open());
1139 }
1140
1141 #[test]
1142 fn test_pull_request_web_url() {
1143 let pr = create_test_pull_request(123, PullRequestState::Open);
1144 let url = pr.web_url();
1145 assert!(url.is_some());
1146 assert_eq!(
1147 url.unwrap(),
1148 "http://bitbucket.local/projects/TEST/repos/test/pull-requests/123"
1149 );
1150 }
1151
1152 #[test]
1153 fn test_merge_strategy_conversion() {
1154 let squash = MergeStrategy::Squash;
1155 let merge = MergeStrategy::Merge;
1156 let ff = MergeStrategy::FastForward;
1157
1158 assert!(matches!(squash, MergeStrategy::Squash));
1160 assert!(matches!(merge, MergeStrategy::Merge));
1161 assert!(matches!(ff, MergeStrategy::FastForward));
1162 }
1163
1164 #[test]
1165 fn test_merge_strategy_commit_message() {
1166 let pr = create_test_pull_request(1, PullRequestState::Open);
1167
1168 let squash_strategy = MergeStrategy::Squash;
1169 let message = squash_strategy.get_commit_message(&pr);
1170 assert!(message.is_some());
1171 assert!(message.unwrap().contains("Test PR"));
1172
1173 let merge_strategy = MergeStrategy::Merge;
1174 let message = merge_strategy.get_commit_message(&pr);
1175 assert!(message.is_none()); let ff_strategy = MergeStrategy::FastForward;
1178 let message = ff_strategy.get_commit_message(&pr);
1179 assert!(message.is_none()); }
1181
1182 #[test]
1183 fn test_auto_merge_conditions_default() {
1184 let conditions = AutoMergeConditions::default();
1185
1186 assert!(conditions.wait_for_builds); assert_eq!(conditions.build_timeout.as_secs(), 1800); assert!(conditions.allowed_authors.is_none());
1189 assert!(matches!(conditions.merge_strategy, MergeStrategy::Squash));
1190 }
1191
1192 #[test]
1193 fn test_auto_merge_conditions_custom() {
1194 let conditions = AutoMergeConditions {
1195 merge_strategy: MergeStrategy::Merge,
1196 wait_for_builds: false,
1197 build_timeout: Duration::from_secs(3600),
1198 allowed_authors: Some(vec!["trusted-user".to_string()]),
1199 };
1200
1201 assert!(matches!(conditions.merge_strategy, MergeStrategy::Merge));
1202 assert!(!conditions.wait_for_builds);
1203 assert_eq!(conditions.build_timeout.as_secs(), 3600);
1204 assert!(conditions.allowed_authors.is_some());
1205 }
1206
1207 #[test]
1208 fn test_pull_request_status_ready_to_land() {
1209 let pr = create_test_pull_request(1, PullRequestState::Open);
1210 let participants = vec![create_test_participant(
1211 ParticipantRole::Reviewer,
1212 ParticipantStatus::Approved,
1213 )];
1214 let review_status = ReviewStatus {
1215 required_approvals: 1,
1216 current_approvals: 1,
1217 needs_work_count: 0,
1218 can_merge: true,
1219 missing_reviewers: vec![],
1220 };
1221
1222 let status = PullRequestStatus {
1223 pr,
1224 mergeable: Some(true),
1225 mergeable_details: None,
1226 participants,
1227 build_status: Some(create_test_build_status(BuildState::Successful)),
1228 review_status,
1229 conflicts: None,
1230 };
1231
1232 assert!(status.is_ready_to_land());
1233 }
1234
1235 #[test]
1236 fn test_pull_request_status_not_ready_to_land() {
1237 let pr = create_test_pull_request(1, PullRequestState::Open);
1238 let participants = vec![create_test_participant(
1239 ParticipantRole::Reviewer,
1240 ParticipantStatus::Unapproved,
1241 )];
1242 let review_status = ReviewStatus {
1243 required_approvals: 1,
1244 current_approvals: 0,
1245 needs_work_count: 0,
1246 can_merge: false,
1247 missing_reviewers: vec!["reviewer".to_string()],
1248 };
1249
1250 let status = PullRequestStatus {
1251 pr,
1252 mergeable: Some(false),
1253 mergeable_details: None,
1254 participants,
1255 build_status: Some(create_test_build_status(BuildState::Failed)),
1256 review_status,
1257 conflicts: Some(vec!["Conflict in file.txt".to_string()]),
1258 };
1259
1260 assert!(!status.is_ready_to_land());
1261 }
1262
1263 #[test]
1264 fn test_pull_request_status_blocking_reasons() {
1265 let pr_status = PullRequestStatus {
1267 pr: create_test_pull_request(1, PullRequestState::Open),
1268 mergeable: Some(true),
1269 mergeable_details: None,
1270 participants: vec![create_test_participant(
1271 ParticipantRole::Author,
1272 ParticipantStatus::Approved,
1273 )],
1274 build_status: Some(create_test_build_status(BuildState::Failed)),
1275 review_status: ReviewStatus {
1276 required_approvals: 1,
1277 current_approvals: 0, needs_work_count: 0,
1279 can_merge: false,
1280 missing_reviewers: vec!["reviewer1".to_string()],
1281 },
1282 conflicts: None,
1283 };
1284
1285 let blocking_reasons = pr_status.get_blocking_reasons();
1286
1287 assert!(!blocking_reasons.is_empty());
1289
1290 assert!(blocking_reasons.iter().any(|r| r.contains("Build failed")));
1292
1293 assert!(blocking_reasons
1295 .iter()
1296 .any(|r| r.contains("No approvals yet")));
1297 }
1298
1299 #[test]
1300 fn test_pull_request_status_can_auto_merge() {
1301 let pr = create_test_pull_request(1, PullRequestState::Open);
1302 let participants = vec![create_test_participant(
1303 ParticipantRole::Reviewer,
1304 ParticipantStatus::Approved,
1305 )];
1306 let review_status = ReviewStatus {
1307 required_approvals: 1,
1308 current_approvals: 1,
1309 needs_work_count: 0,
1310 can_merge: true,
1311 missing_reviewers: vec![],
1312 };
1313
1314 let status = PullRequestStatus {
1315 pr,
1316 mergeable: Some(true),
1317 mergeable_details: None,
1318 participants,
1319 build_status: Some(create_test_build_status(BuildState::Successful)),
1320 review_status,
1321 conflicts: None,
1322 };
1323
1324 let conditions = AutoMergeConditions::default();
1325 assert!(status.can_auto_merge(&conditions));
1326
1327 let allowlist_conditions = AutoMergeConditions {
1329 allowed_authors: Some(vec!["testuser".to_string()]),
1330 ..Default::default()
1331 };
1332 assert!(status.can_auto_merge(&allowlist_conditions));
1333
1334 let mut status_not_mergeable = status.clone();
1336 status_not_mergeable.mergeable = Some(false);
1337 assert!(!status_not_mergeable.can_auto_merge(&conditions));
1338 }
1339
1340 #[test]
1341 fn test_build_state_variants() {
1342 let _successful = BuildState::Successful;
1344 let _failed = BuildState::Failed;
1345 let _in_progress = BuildState::InProgress;
1346 let _cancelled = BuildState::Cancelled;
1347 let _unknown = BuildState::Unknown;
1348
1349 }
1352
1353 #[test]
1354 fn test_review_status_calculations() {
1355 let review_status = ReviewStatus {
1356 required_approvals: 2,
1357 current_approvals: 1,
1358 needs_work_count: 0,
1359 can_merge: false,
1360 missing_reviewers: vec!["reviewer2".to_string()],
1361 };
1362
1363 assert_eq!(review_status.required_approvals, 2);
1364 assert_eq!(review_status.current_approvals, 1);
1365 assert_eq!(review_status.needs_work_count, 0);
1366 assert!(!review_status.can_merge);
1367 assert_eq!(review_status.missing_reviewers.len(), 1);
1368 }
1369
1370 #[test]
1371 fn test_auto_merge_result_variants() {
1372 let pr = create_test_pull_request(1, PullRequestState::Merged);
1373
1374 let merged_result = AutoMergeResult::Merged {
1376 pr: Box::new(pr.clone()),
1377 merge_strategy: MergeStrategy::Squash,
1378 };
1379 assert!(matches!(merged_result, AutoMergeResult::Merged { .. }));
1380
1381 let not_ready_result = AutoMergeResult::NotReady {
1383 blocking_reasons: vec!["Missing approvals".to_string()],
1384 };
1385 assert!(matches!(not_ready_result, AutoMergeResult::NotReady { .. }));
1386
1387 let failed_result = AutoMergeResult::Failed {
1389 error: "Network error".to_string(),
1390 };
1391 assert!(matches!(failed_result, AutoMergeResult::Failed { .. }));
1392 }
1393
1394 #[test]
1395 fn test_participant_roles_and_status() {
1396 let author = create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved);
1397 assert!(matches!(author.role, ParticipantRole::Author));
1398 assert!(author.approved);
1399
1400 let reviewer =
1401 create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::Unapproved);
1402 assert!(matches!(reviewer.role, ParticipantRole::Reviewer));
1403 assert!(!reviewer.approved);
1404
1405 let needs_work =
1406 create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::NeedsWork);
1407 assert!(matches!(needs_work.status, ParticipantStatus::NeedsWork));
1408 assert!(!needs_work.approved);
1409 }
1410
1411 #[test]
1412 fn test_polling_frequency_constant() {
1413 use std::time::Duration;
1415
1416 let polling_interval = Duration::from_secs(30);
1417 assert_eq!(polling_interval.as_secs(), 30);
1418
1419 assert!(polling_interval.as_secs() >= 10);
1421 assert!(polling_interval.as_secs() <= 60);
1422 }
1423}