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