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(participant.user.display_name.clone());
336 }
337 }
338 }
339 }
340
341 let required_approvals = 1; let can_merge = current_approvals >= required_approvals && needs_work_count == 0;
344
345 Ok(ReviewStatus {
346 required_approvals,
347 current_approvals,
348 needs_work_count,
349 can_merge,
350 missing_reviewers,
351 })
352 }
353
354 pub async fn merge_pull_request(
356 &self,
357 pr_id: u64,
358 merge_strategy: MergeStrategy,
359 ) -> Result<PullRequest> {
360 let pr = self.get_pull_request(pr_id).await?;
361
362 let merge_request = MergePullRequestRequest {
363 version: pr.version,
364 message: merge_strategy.get_commit_message(&pr),
365 strategy: merge_strategy,
366 };
367
368 self.client
369 .post(&format!("pull-requests/{pr_id}/merge"), &merge_request)
370 .await
371 }
372
373 pub async fn auto_merge_if_ready(
375 &self,
376 pr_id: u64,
377 conditions: &AutoMergeConditions,
378 ) -> Result<AutoMergeResult> {
379 let status = self.get_pull_request_status(pr_id).await?;
380
381 if !status.can_auto_merge(conditions) {
382 return Ok(AutoMergeResult::NotReady {
383 blocking_reasons: status.get_blocking_reasons(),
384 });
385 }
386
387 if conditions.wait_for_builds {
389 self.wait_for_builds(pr_id, conditions.build_timeout)
390 .await?;
391 }
392
393 let merged_pr = self
395 .merge_pull_request(pr_id, conditions.merge_strategy.clone())
396 .await?;
397
398 Ok(AutoMergeResult::Merged {
399 pr: Box::new(merged_pr),
400 merge_strategy: conditions.merge_strategy.clone(),
401 })
402 }
403
404 async fn wait_for_builds(&self, pr_id: u64, timeout: Duration) -> Result<()> {
406 use tokio::time::{sleep, timeout as tokio_timeout};
407
408 tokio_timeout(timeout, async {
409 loop {
410 let build_status = self.get_build_status(pr_id).await?;
411
412 match build_status.state {
413 BuildState::Successful => return Ok(()),
414 BuildState::Failed | BuildState::Cancelled => {
415 return Err(CascadeError::bitbucket(format!(
416 "Build failed: {}",
417 build_status.description.unwrap_or_default()
418 )));
419 }
420 BuildState::InProgress => {
421 sleep(Duration::from_secs(30)).await; continue;
423 }
424 BuildState::Unknown => {
425 return Err(CascadeError::bitbucket("Build status unknown".to_string()));
426 }
427 }
428 }
429 })
430 .await
431 .map_err(|_| CascadeError::bitbucket("Build timeout exceeded".to_string()))?
432 }
433}
434
435#[derive(Debug, Serialize, Deserialize, Clone)]
437pub struct CreatePullRequestRequest {
438 pub title: String,
439 pub description: Option<String>,
440 #[serde(rename = "fromRef")]
441 pub from_ref: PullRequestRef,
442 #[serde(rename = "toRef")]
443 pub to_ref: PullRequestRef,
444 #[serde(rename = "isDraft")]
445 pub draft: bool,
446}
447
448#[derive(Debug, Clone, Deserialize, Serialize)]
450pub struct PullRequest {
451 pub id: u64,
452 pub version: u64,
453 pub title: String,
454 pub description: Option<String>,
455 pub state: PullRequestState,
456 pub open: bool,
457 pub closed: bool,
458 #[serde(rename = "createdDate")]
459 pub created_date: u64,
460 #[serde(rename = "updatedDate")]
461 pub updated_date: u64,
462 #[serde(rename = "fromRef")]
463 pub from_ref: PullRequestRef,
464 #[serde(rename = "toRef")]
465 pub to_ref: PullRequestRef,
466 pub locked: bool,
467 pub author: Participant,
468 pub links: PullRequestLinks,
469}
470
471#[derive(Debug, Clone, Deserialize, Serialize)]
473pub struct PullRequestRef {
474 pub id: String,
475 #[serde(rename = "displayId")]
476 pub display_id: String,
477 #[serde(rename = "latestCommit")]
478 pub latest_commit: String,
479 pub repository: Repository,
480}
481
482#[derive(Debug, Clone, Deserialize, Serialize)]
484pub struct Repository {
485 pub id: u64,
486 pub name: String,
487 pub slug: String,
488 #[serde(rename = "scmId")]
489 pub scm_id: String,
490 pub state: String,
491 #[serde(rename = "statusMessage")]
492 pub status_message: String,
493 pub forkable: bool,
494 pub project: Project,
495 pub public: bool,
496}
497
498#[derive(Debug, Clone, Deserialize, Serialize)]
500pub struct Project {
501 pub id: u64,
502 pub key: String,
503 pub name: String,
504 pub description: Option<String>,
505 pub public: bool,
506 #[serde(rename = "type")]
507 pub project_type: String,
508}
509
510#[derive(Debug, Clone, Deserialize, Serialize)]
512pub struct PullRequestLinks {
513 #[serde(rename = "self")]
514 pub self_link: Vec<SelfLink>,
515}
516
517#[derive(Debug, Clone, Deserialize, Serialize)]
519pub struct SelfLink {
520 pub href: String,
521}
522
523#[derive(Debug, Clone, Deserialize, Serialize)]
525pub struct Participant {
526 pub user: User,
527 pub role: ParticipantRole,
528 pub approved: bool,
529 pub status: ParticipantStatus,
530}
531
532#[derive(Debug, Clone, Deserialize, Serialize)]
534pub struct User {
535 pub name: String,
536 #[serde(rename = "displayName")]
537 pub display_name: String,
538 #[serde(rename = "emailAddress")]
539 pub email_address: String,
540 pub active: bool,
541 pub slug: String,
542}
543
544#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
546#[serde(rename_all = "UPPERCASE")]
547pub enum PullRequestState {
548 Open,
549 Merged,
550 Declined,
551}
552
553impl PullRequestState {
554 pub fn as_str(&self) -> &'static str {
555 match self {
556 Self::Open => "OPEN",
557 Self::Merged => "MERGED",
558 Self::Declined => "DECLINED",
559 }
560 }
561}
562
563#[derive(Debug, Clone, Deserialize, Serialize)]
565#[serde(rename_all = "UPPERCASE")]
566pub enum ParticipantRole {
567 Author,
568 Reviewer,
569 Participant,
570}
571
572#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
574#[serde(rename_all = "UPPERCASE")]
575pub enum ParticipantStatus {
576 Approved,
577 Unapproved,
578 #[serde(rename = "NEEDS_WORK")]
579 NeedsWork,
580}
581
582#[derive(Debug, Deserialize)]
584pub struct PullRequestPage {
585 pub size: u32,
586 pub limit: u32,
587 #[serde(rename = "isLastPage")]
588 pub is_last_page: bool,
589 pub values: Vec<PullRequest>,
590 pub start: u32,
591 #[serde(rename = "nextPageStart")]
592 pub next_page_start: Option<u32>,
593}
594
595impl PullRequest {
596 pub fn web_url(&self) -> Option<String> {
598 self.links.self_link.first().map(|link| link.href.clone())
599 }
600
601 pub fn is_open(&self) -> bool {
603 self.state == PullRequestState::Open && self.open && !self.closed
604 }
605
606 pub fn created_at(&self) -> DateTime<Utc> {
608 DateTime::from_timestamp(self.created_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
609 }
610
611 pub fn updated_at(&self) -> DateTime<Utc> {
613 DateTime::from_timestamp(self.updated_date as i64 / 1000, 0).unwrap_or_else(Utc::now)
614 }
615}
616
617#[derive(Debug, Clone, Deserialize, Serialize)]
619pub struct PullRequestStatus {
620 pub pr: PullRequest,
621 pub mergeable: Option<bool>,
622 pub mergeable_details: Option<MergeabilityDetails>,
623 pub participants: Vec<Participant>,
624 pub build_status: Option<BuildStatus>,
625 pub review_status: ReviewStatus,
626 pub conflicts: Option<Vec<String>>,
627}
628
629#[derive(Debug, Clone, Deserialize, Serialize)]
631pub struct BuildStatus {
632 pub state: BuildState,
633 pub url: Option<String>,
634 pub description: Option<String>,
635 pub context: Option<String>,
636}
637
638#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
640#[serde(rename_all = "UPPERCASE")]
641pub enum BuildState {
642 Successful,
643 Failed,
644 InProgress,
645 Cancelled,
646 Unknown,
647}
648
649#[derive(Debug, Clone, Deserialize, Serialize)]
651pub struct ReviewStatus {
652 pub required_approvals: usize,
653 pub current_approvals: usize,
654 pub needs_work_count: usize,
655 pub can_merge: bool,
656 pub missing_reviewers: Vec<String>,
657}
658
659#[derive(Debug, Deserialize)]
661struct ParticipantsResponse {
662 pub values: Vec<Participant>,
663}
664
665#[derive(Debug, Deserialize)]
667#[allow(dead_code)]
668struct MergeabilityResponse {
669 #[serde(rename = "canMerge")]
670 pub can_merge: bool,
671 pub conflicted: Option<bool>,
672}
673
674#[derive(Debug, Deserialize)]
676struct BuildStatusResponse {
677 pub values: Vec<BuildInfo>,
678}
679
680#[derive(Debug, Deserialize)]
682struct BuildInfo {
683 pub state: BuildState,
684 pub name: Option<String>,
685 pub url: Option<String>,
686 pub description: Option<String>,
687}
688
689#[derive(Debug, Deserialize)]
691#[allow(dead_code)]
692struct DiffResponse {
693 pub diffs: Vec<serde_json::Value>, }
695
696impl PullRequestStatus {
697 pub fn get_display_status(&self) -> String {
699 if self.pr.state != PullRequestState::Open {
700 return format!("{:?}", self.pr.state).to_uppercase();
701 }
702
703 let mut status_parts = Vec::new();
704
705 if let Some(build) = &self.build_status {
707 match build.state {
708 BuildState::Successful => status_parts.push("✅ Builds"),
709 BuildState::Failed => status_parts.push("❌ Builds"),
710 BuildState::InProgress => status_parts.push("🔄 Building"),
711 _ => status_parts.push("⚪ Builds"),
712 }
713 }
714
715 if self.review_status.can_merge {
717 status_parts.push("✅ Reviews");
718 } else if self.review_status.needs_work_count > 0 {
719 status_parts.push("❌ Reviews");
720 } else {
721 status_parts.push("⏳ Reviews");
722 }
723
724 if let Some(mergeable) = self.mergeable {
726 if mergeable {
727 status_parts.push("✅ Mergeable");
728 } else {
729 status_parts.push("❌ Conflicts");
730 }
731 }
732
733 if status_parts.is_empty() {
734 "🔄 Open".to_string()
735 } else {
736 status_parts.join(" | ")
737 }
738 }
739
740 pub fn is_ready_to_land(&self) -> bool {
742 self.pr.state == PullRequestState::Open
743 && self.review_status.can_merge
744 && self.mergeable.unwrap_or(false)
745 && matches!(
746 self.build_status.as_ref().map(|b| &b.state),
747 Some(BuildState::Successful) | None
748 )
749 }
750
751 pub fn get_blocking_reasons(&self) -> Vec<String> {
753 let mut reasons = Vec::new();
754
755 if let Some(mergeable_details) = &self.mergeable_details {
761 if !mergeable_details.can_merge {
762 for reason in &mergeable_details.blocking_reasons {
764 reasons.push(format!("🔒 Server Check: {reason}"));
765 }
766
767 if mergeable_details.blocking_reasons.is_empty() {
769 reasons.push("🔒 Server Check: Merge blocked by repository policy".to_string());
770 }
771 }
772 } else if self.mergeable == Some(false) {
773 reasons.push("🔒 Server Check: Merge blocked by repository policy".to_string());
775 }
776
777 if !self.pr.is_open() {
779 reasons.push(format!(
780 "❌ PR Status: Pull request is {}",
781 self.pr.state.as_str()
782 ));
783 }
784
785 if let Some(build_status) = &self.build_status {
787 match build_status.state {
788 BuildState::Failed => reasons.push("❌ Build Status: Build failed".to_string()),
789 BuildState::InProgress => {
790 reasons.push("⏳ Build Status: Build in progress".to_string())
791 }
792 BuildState::Cancelled => {
793 reasons.push("❌ Build Status: Build cancelled".to_string())
794 }
795 BuildState::Unknown => {
796 reasons.push("❓ Build Status: Build status unknown".to_string())
797 }
798 BuildState::Successful => {} }
800 }
801
802 if !self.review_status.can_merge {
804 if self.review_status.current_approvals < self.review_status.required_approvals {
805 reasons.push(format!(
806 "👥 Review Status: Need {} more approval{} ({}/{})",
807 self.review_status.required_approvals - self.review_status.current_approvals,
808 if self.review_status.required_approvals - self.review_status.current_approvals
809 == 1
810 {
811 ""
812 } else {
813 "s"
814 },
815 self.review_status.current_approvals,
816 self.review_status.required_approvals
817 ));
818 }
819
820 if self.review_status.needs_work_count > 0 {
821 reasons.push(format!(
822 "👥 Review Status: {} reviewer{} requested changes",
823 self.review_status.needs_work_count,
824 if self.review_status.needs_work_count == 1 {
825 ""
826 } else {
827 "s"
828 }
829 ));
830 }
831
832 if !self.review_status.missing_reviewers.is_empty() {
833 reasons.push(format!(
834 "👥 Review Status: Missing approval from: {}",
835 self.review_status.missing_reviewers.join(", ")
836 ));
837 }
838 }
839
840 if let Some(conflicts) = &self.conflicts {
842 if !conflicts.is_empty() {
843 reasons.push(format!(
844 "⚠️ Merge Conflicts: {} file{} with conflicts",
845 conflicts.len(),
846 if conflicts.len() == 1 { "" } else { "s" }
847 ));
848 }
849 }
850
851 reasons
852 }
853
854 pub fn can_auto_merge(&self, conditions: &AutoMergeConditions) -> bool {
856 if !self.pr.is_open() {
858 return false;
859 }
860
861 if let Some(allowed_authors) = &conditions.allowed_authors {
863 if !allowed_authors.contains(&self.pr.author.user.name) {
864 return false;
865 }
866 }
867
868 self.mergeable.unwrap_or(false)
871 }
872}
873
874#[derive(Debug, Clone)]
876pub struct AutoMergeConditions {
877 pub merge_strategy: MergeStrategy,
878 pub wait_for_builds: bool,
879 pub build_timeout: Duration,
880 pub allowed_authors: Option<Vec<String>>, }
882
883impl Default for AutoMergeConditions {
884 fn default() -> Self {
885 Self {
886 merge_strategy: MergeStrategy::Squash,
887 wait_for_builds: true,
888 build_timeout: Duration::from_secs(1800), allowed_authors: None,
890 }
891 }
892}
893
894#[derive(Debug, Clone, Serialize, Deserialize)]
896#[serde(rename_all = "kebab-case")]
897pub enum MergeStrategy {
898 #[serde(rename = "merge-commit")]
899 Merge,
900 #[serde(rename = "squash")]
901 Squash,
902 #[serde(rename = "fast-forward")]
903 FastForward,
904}
905
906impl MergeStrategy {
907 pub fn get_commit_message(&self, pr: &PullRequest) -> Option<String> {
908 match self {
909 MergeStrategy::Squash => Some(format!(
910 "{}\n\n{}",
911 pr.title,
912 pr.description.as_deref().unwrap_or("")
913 )),
914 _ => None, }
916 }
917}
918
919#[derive(Debug)]
921pub enum AutoMergeResult {
922 Merged {
923 pr: Box<PullRequest>,
924 merge_strategy: MergeStrategy,
925 },
926 NotReady {
927 blocking_reasons: Vec<String>,
928 },
929 Failed {
930 error: String,
931 },
932}
933
934#[derive(Debug, Serialize)]
936struct MergePullRequestRequest {
937 version: u64,
938 #[serde(skip_serializing_if = "Option::is_none")]
939 message: Option<String>,
940 #[serde(rename = "strategy")]
941 strategy: MergeStrategy,
942}
943
944#[derive(Debug, Clone, Deserialize, Serialize)]
946pub struct MergeabilityDetails {
947 pub can_merge: bool,
948 pub conflicted: bool,
949 pub blocking_reasons: Vec<String>,
950 pub server_enforced: bool,
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956 use std::time::Duration;
957
958 fn create_test_pull_request(id: u64, state: PullRequestState) -> PullRequest {
960 let is_open = state == PullRequestState::Open;
961 PullRequest {
962 id,
963 version: 1,
964 title: "Test PR".to_string(),
965 description: Some("Test description".to_string()),
966 state: state.clone(),
967 open: is_open,
968 closed: !is_open,
969 created_date: 1700000000000, updated_date: 1700000000000,
971 from_ref: PullRequestRef {
972 id: "refs/heads/feature".to_string(),
973 display_id: "feature".to_string(),
974 latest_commit: "abc123".to_string(),
975 repository: create_test_repository(),
976 },
977 to_ref: PullRequestRef {
978 id: "refs/heads/main".to_string(),
979 display_id: "main".to_string(),
980 latest_commit: "def456".to_string(),
981 repository: create_test_repository(),
982 },
983 locked: false,
984 author: create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved),
985 links: PullRequestLinks {
986 self_link: vec![SelfLink {
987 href: format!(
988 "http://bitbucket.local/projects/TEST/repos/test/pull-requests/{id}"
989 ),
990 }],
991 },
992 }
993 }
994
995 fn create_test_repository() -> Repository {
996 Repository {
997 id: 1,
998 name: "test-repo".to_string(),
999 slug: "test-repo".to_string(),
1000 scm_id: "git".to_string(),
1001 state: "AVAILABLE".to_string(),
1002 status_message: "Available".to_string(),
1003 forkable: true,
1004 project: Project {
1005 id: 1,
1006 key: "TEST".to_string(),
1007 name: "Test Project".to_string(),
1008 description: Some("Test project description".to_string()),
1009 public: false,
1010 project_type: "NORMAL".to_string(),
1011 },
1012 public: false,
1013 }
1014 }
1015
1016 fn create_test_participant(role: ParticipantRole, status: ParticipantStatus) -> Participant {
1017 Participant {
1018 user: User {
1019 name: "testuser".to_string(),
1020 display_name: "Test User".to_string(),
1021 email_address: "test@example.com".to_string(),
1022 active: true,
1023 slug: "testuser".to_string(),
1024 },
1025 role,
1026 approved: status == ParticipantStatus::Approved,
1027 status,
1028 }
1029 }
1030
1031 fn create_test_build_status(state: BuildState) -> BuildStatus {
1032 BuildStatus {
1033 state,
1034 url: Some("http://ci.example.com/build/123".to_string()),
1035 description: Some("Test build".to_string()),
1036 context: Some("CI/CD".to_string()),
1037 }
1038 }
1039
1040 #[test]
1041 fn test_pull_request_state_serialization() {
1042 assert_eq!(PullRequestState::Open.as_str(), "OPEN");
1043 assert_eq!(PullRequestState::Merged.as_str(), "MERGED");
1044 assert_eq!(PullRequestState::Declined.as_str(), "DECLINED");
1045 }
1046
1047 #[test]
1048 fn test_pull_request_is_open() {
1049 let open_pr = create_test_pull_request(1, PullRequestState::Open);
1050 assert!(open_pr.is_open());
1051
1052 let merged_pr = create_test_pull_request(2, PullRequestState::Merged);
1053 assert!(!merged_pr.is_open());
1054
1055 let declined_pr = create_test_pull_request(3, PullRequestState::Declined);
1056 assert!(!declined_pr.is_open());
1057 }
1058
1059 #[test]
1060 fn test_pull_request_web_url() {
1061 let pr = create_test_pull_request(123, PullRequestState::Open);
1062 let url = pr.web_url();
1063 assert!(url.is_some());
1064 assert_eq!(
1065 url.unwrap(),
1066 "http://bitbucket.local/projects/TEST/repos/test/pull-requests/123"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_merge_strategy_conversion() {
1072 let squash = MergeStrategy::Squash;
1073 let merge = MergeStrategy::Merge;
1074 let ff = MergeStrategy::FastForward;
1075
1076 assert!(matches!(squash, MergeStrategy::Squash));
1078 assert!(matches!(merge, MergeStrategy::Merge));
1079 assert!(matches!(ff, MergeStrategy::FastForward));
1080 }
1081
1082 #[test]
1083 fn test_merge_strategy_commit_message() {
1084 let pr = create_test_pull_request(1, PullRequestState::Open);
1085
1086 let squash_strategy = MergeStrategy::Squash;
1087 let message = squash_strategy.get_commit_message(&pr);
1088 assert!(message.is_some());
1089 assert!(message.unwrap().contains("Test PR"));
1090
1091 let merge_strategy = MergeStrategy::Merge;
1092 let message = merge_strategy.get_commit_message(&pr);
1093 assert!(message.is_none()); let ff_strategy = MergeStrategy::FastForward;
1096 let message = ff_strategy.get_commit_message(&pr);
1097 assert!(message.is_none()); }
1099
1100 #[test]
1101 fn test_auto_merge_conditions_default() {
1102 let conditions = AutoMergeConditions::default();
1103
1104 assert!(conditions.wait_for_builds); assert_eq!(conditions.build_timeout.as_secs(), 1800); assert!(conditions.allowed_authors.is_none());
1107 assert!(matches!(conditions.merge_strategy, MergeStrategy::Squash));
1108 }
1109
1110 #[test]
1111 fn test_auto_merge_conditions_custom() {
1112 let conditions = AutoMergeConditions {
1113 merge_strategy: MergeStrategy::Merge,
1114 wait_for_builds: false,
1115 build_timeout: Duration::from_secs(3600),
1116 allowed_authors: Some(vec!["trusted-user".to_string()]),
1117 };
1118
1119 assert!(matches!(conditions.merge_strategy, MergeStrategy::Merge));
1120 assert!(!conditions.wait_for_builds);
1121 assert_eq!(conditions.build_timeout.as_secs(), 3600);
1122 assert!(conditions.allowed_authors.is_some());
1123 }
1124
1125 #[test]
1126 fn test_pull_request_status_ready_to_land() {
1127 let pr = create_test_pull_request(1, PullRequestState::Open);
1128 let participants = vec![create_test_participant(
1129 ParticipantRole::Reviewer,
1130 ParticipantStatus::Approved,
1131 )];
1132 let review_status = ReviewStatus {
1133 required_approvals: 1,
1134 current_approvals: 1,
1135 needs_work_count: 0,
1136 can_merge: true,
1137 missing_reviewers: vec![],
1138 };
1139
1140 let status = PullRequestStatus {
1141 pr,
1142 mergeable: Some(true),
1143 mergeable_details: None,
1144 participants,
1145 build_status: Some(create_test_build_status(BuildState::Successful)),
1146 review_status,
1147 conflicts: None,
1148 };
1149
1150 assert!(status.is_ready_to_land());
1151 }
1152
1153 #[test]
1154 fn test_pull_request_status_not_ready_to_land() {
1155 let pr = create_test_pull_request(1, PullRequestState::Open);
1156 let participants = vec![create_test_participant(
1157 ParticipantRole::Reviewer,
1158 ParticipantStatus::Unapproved,
1159 )];
1160 let review_status = ReviewStatus {
1161 required_approvals: 1,
1162 current_approvals: 0,
1163 needs_work_count: 0,
1164 can_merge: false,
1165 missing_reviewers: vec!["reviewer".to_string()],
1166 };
1167
1168 let status = PullRequestStatus {
1169 pr,
1170 mergeable: Some(false),
1171 mergeable_details: None,
1172 participants,
1173 build_status: Some(create_test_build_status(BuildState::Failed)),
1174 review_status,
1175 conflicts: Some(vec!["Conflict in file.txt".to_string()]),
1176 };
1177
1178 assert!(!status.is_ready_to_land());
1179 }
1180
1181 #[test]
1182 fn test_pull_request_status_blocking_reasons() {
1183 let pr_status = PullRequestStatus {
1185 pr: create_test_pull_request(1, PullRequestState::Open),
1186 mergeable: Some(true),
1187 mergeable_details: None,
1188 participants: vec![create_test_participant(
1189 ParticipantRole::Author,
1190 ParticipantStatus::Approved,
1191 )],
1192 build_status: Some(create_test_build_status(BuildState::Failed)),
1193 review_status: ReviewStatus {
1194 required_approvals: 1,
1195 current_approvals: 0, needs_work_count: 0,
1197 can_merge: false,
1198 missing_reviewers: vec!["reviewer1".to_string()],
1199 },
1200 conflicts: None,
1201 };
1202
1203 let blocking_reasons = pr_status.get_blocking_reasons();
1204
1205 assert!(!blocking_reasons.is_empty());
1207
1208 assert!(blocking_reasons.iter().any(|r| r.contains("Build failed")));
1210
1211 assert!(blocking_reasons.iter().any(|r| r.contains("more approval")));
1213 }
1214
1215 #[test]
1216 fn test_pull_request_status_can_auto_merge() {
1217 let pr = create_test_pull_request(1, PullRequestState::Open);
1218 let participants = vec![create_test_participant(
1219 ParticipantRole::Reviewer,
1220 ParticipantStatus::Approved,
1221 )];
1222 let review_status = ReviewStatus {
1223 required_approvals: 1,
1224 current_approvals: 1,
1225 needs_work_count: 0,
1226 can_merge: true,
1227 missing_reviewers: vec![],
1228 };
1229
1230 let status = PullRequestStatus {
1231 pr,
1232 mergeable: Some(true),
1233 mergeable_details: None,
1234 participants,
1235 build_status: Some(create_test_build_status(BuildState::Successful)),
1236 review_status,
1237 conflicts: None,
1238 };
1239
1240 let conditions = AutoMergeConditions::default();
1241 assert!(status.can_auto_merge(&conditions));
1242
1243 let allowlist_conditions = AutoMergeConditions {
1245 allowed_authors: Some(vec!["testuser".to_string()]),
1246 ..Default::default()
1247 };
1248 assert!(status.can_auto_merge(&allowlist_conditions));
1249
1250 let mut status_not_mergeable = status.clone();
1252 status_not_mergeable.mergeable = Some(false);
1253 assert!(!status_not_mergeable.can_auto_merge(&conditions));
1254 }
1255
1256 #[test]
1257 fn test_build_state_variants() {
1258 let _successful = BuildState::Successful;
1260 let _failed = BuildState::Failed;
1261 let _in_progress = BuildState::InProgress;
1262 let _cancelled = BuildState::Cancelled;
1263 let _unknown = BuildState::Unknown;
1264
1265 }
1268
1269 #[test]
1270 fn test_review_status_calculations() {
1271 let review_status = ReviewStatus {
1272 required_approvals: 2,
1273 current_approvals: 1,
1274 needs_work_count: 0,
1275 can_merge: false,
1276 missing_reviewers: vec!["reviewer2".to_string()],
1277 };
1278
1279 assert_eq!(review_status.required_approvals, 2);
1280 assert_eq!(review_status.current_approvals, 1);
1281 assert_eq!(review_status.needs_work_count, 0);
1282 assert!(!review_status.can_merge);
1283 assert_eq!(review_status.missing_reviewers.len(), 1);
1284 }
1285
1286 #[test]
1287 fn test_auto_merge_result_variants() {
1288 let pr = create_test_pull_request(1, PullRequestState::Merged);
1289
1290 let merged_result = AutoMergeResult::Merged {
1292 pr: Box::new(pr.clone()),
1293 merge_strategy: MergeStrategy::Squash,
1294 };
1295 assert!(matches!(merged_result, AutoMergeResult::Merged { .. }));
1296
1297 let not_ready_result = AutoMergeResult::NotReady {
1299 blocking_reasons: vec!["Missing approvals".to_string()],
1300 };
1301 assert!(matches!(not_ready_result, AutoMergeResult::NotReady { .. }));
1302
1303 let failed_result = AutoMergeResult::Failed {
1305 error: "Network error".to_string(),
1306 };
1307 assert!(matches!(failed_result, AutoMergeResult::Failed { .. }));
1308 }
1309
1310 #[test]
1311 fn test_participant_roles_and_status() {
1312 let author = create_test_participant(ParticipantRole::Author, ParticipantStatus::Approved);
1313 assert!(matches!(author.role, ParticipantRole::Author));
1314 assert!(author.approved);
1315
1316 let reviewer =
1317 create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::Unapproved);
1318 assert!(matches!(reviewer.role, ParticipantRole::Reviewer));
1319 assert!(!reviewer.approved);
1320
1321 let needs_work =
1322 create_test_participant(ParticipantRole::Reviewer, ParticipantStatus::NeedsWork);
1323 assert!(matches!(needs_work.status, ParticipantStatus::NeedsWork));
1324 assert!(!needs_work.approved);
1325 }
1326
1327 #[test]
1328 fn test_polling_frequency_constant() {
1329 use std::time::Duration;
1331
1332 let polling_interval = Duration::from_secs(30);
1333 assert_eq!(polling_interval.as_secs(), 30);
1334
1335 assert!(polling_interval.as_secs() >= 10);
1337 assert!(polling_interval.as_secs() <= 60);
1338 }
1339}