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