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