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