diff --git a/.sqlx/query-711316962e9434e549af5de738de2b415d0ccdfd4f134a21e69a42adbc6c8aae.json b/.sqlx/query-711316962e9434e549af5de738de2b415d0ccdfd4f134a21e69a42adbc6c8aae.json new file mode 100644 index 00000000..212fe70a --- /dev/null +++ b/.sqlx/query-711316962e9434e549af5de738de2b415d0ccdfd4f134a21e69a42adbc6c8aae.json @@ -0,0 +1,124 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n (\n pr.approved_by,\n pr.approved_sha\n ) AS \"approval_status!: ApprovalStatus\",\n pr.status as \"pr_status: PullRequestStatus\",\n pr.priority,\n pr.rollup as \"rollup: RollupMode\",\n pr.delegated_permission as \"delegated_permission: DelegatedPermission\",\n pr.base_branch,\n pr.mergeable_state as \"mergeable_state: MergeableState\",\n pr.created_at as \"created_at: DateTime\",\n build AS \"try_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build ON pr.build_id = build.id\n WHERE pr.repository = $1\n AND pr.status IN ('open', 'draft')\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "repository: GithubRepoName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "number!: i64", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "approval_status!: ApprovalStatus", + "type_info": "Record" + }, + { + "ordinal": 4, + "name": "pr_status: PullRequestStatus", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "rollup: RollupMode", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "delegated_permission: DelegatedPermission", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "base_branch", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "mergeable_state: MergeableState", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "created_at: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "try_build: BuildModel", + "type_info": { + "Custom": { + "name": "build", + "kind": { + "Composite": [ + [ + "id", + "Int4" + ], + [ + "repository", + "Text" + ], + [ + "branch", + "Text" + ], + [ + "commit_sha", + "Text" + ], + [ + "status", + "Text" + ], + [ + "parent", + "Text" + ], + [ + "created_at", + "Timestamptz" + ] + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null, + false, + true, + true, + true, + false, + false, + false, + null + ] + }, + "hash": "711316962e9434e549af5de738de2b415d0ccdfd4f134a21e69a42adbc6c8aae" +} diff --git a/Cargo.lock b/Cargo.lock index bfef3ae7..bdec79d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1293,6 +1293,7 @@ dependencies = [ "chrono", "either", "futures", + "futures-core", "futures-util", "http", "http-body", diff --git a/Cargo.toml b/Cargo.toml index 37b81590..3522caef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ serde_json = "1" toml = "0.8" # GitHub -octocrab = { version = "0.44", features = ["timeout"] } +octocrab = { version = "0.44", features = ["timeout", "stream"] } # Async futures = "0.3" diff --git a/src/bin/bors.rs b/src/bin/bors.rs index 6f1e2040..b59f76b0 100644 --- a/src/bin/bors.rs +++ b/src/bin/bors.rs @@ -29,6 +29,9 @@ const CANCEL_TIMED_OUT_BUILDS_INTERVAL: Duration = Duration::from_secs(60 * 5); /// How often should the bot reload the mergeability status of PRs? const MERGEABILITY_STATUS_INTERVAL: Duration = Duration::from_secs(60 * 10); +/// How often should the bot synchronize PR state. +const PR_STATE_PERIODIC_REFRESH: Duration = Duration::from_secs(60 * 10); + #[derive(clap::Parser)] struct Opts { /// Github App ID. @@ -141,6 +144,7 @@ fn try_main(opts: Opts) -> anyhow::Result<()> { let mut permissions_refresh = make_interval(PERMISSIONS_REFRESH_INTERVAL); let mut cancel_builds_refresh = make_interval(CANCEL_TIMED_OUT_BUILDS_INTERVAL); let mut mergeability_status_refresh = make_interval(MERGEABILITY_STATUS_INTERVAL); + let mut prs_interval = make_interval(PR_STATE_PERIODIC_REFRESH); loop { tokio::select! { _ = config_refresh.tick() => { @@ -155,6 +159,9 @@ fn try_main(opts: Opts) -> anyhow::Result<()> { _ = mergeability_status_refresh.tick() => { refresh_tx.send(BorsGlobalEvent::RefreshPullRequestMergeability).await?; } + _ = prs_interval.tick() => { + refresh_tx.send(BorsGlobalEvent::RefreshPullRequestState).await?; + } } } }; diff --git a/src/bors/event.rs b/src/bors/event.rs index d5712b72..ec1ebeac 100644 --- a/src/bors/event.rs +++ b/src/bors/event.rs @@ -67,6 +67,8 @@ pub enum BorsGlobalEvent { CancelTimedOutBuilds, /// Refresh mergeability status of PRs that have unknown mergeability status. RefreshPullRequestMergeability, + /// Periodic event that serves for synchronizing PR state. + RefreshPullRequestState, } #[derive(Debug)] diff --git a/src/bors/handlers/mod.rs b/src/bors/handlers/mod.rs index ad0ebb8c..7fe99314 100644 --- a/src/bors/handlers/mod.rs +++ b/src/bors/handlers/mod.rs @@ -28,6 +28,7 @@ use pr_events::{ handle_pull_request_merged, handle_pull_request_opened, handle_pull_request_ready_for_review, handle_pull_request_reopened, handle_push_to_branch, handle_push_to_pull_request, }; +use refresh::sync_pull_requests_state; use review::{command_delegate, command_set_priority, command_set_rollup, command_undelegate}; use tracing::Instrument; @@ -283,6 +284,18 @@ pub async fn handle_bors_global_event( #[cfg(test)] crate::bors::WAIT_FOR_MERGEABILITY_STATUS_REFRESH.mark(); } + BorsGlobalEvent::RefreshPullRequestState => { + let span = tracing::info_span!("Refresh"); + for_each_repo(&ctx, |repo| { + let subspan = tracing::info_span!("Repo", repo = repo.repository().to_string()); + sync_pull_requests_state(repo, Arc::clone(&db)).instrument(subspan) + }) + .instrument(span) + .await?; + + #[cfg(test)] + crate::bors::WAIT_FOR_PR_STATUS_REFRESH.mark(); + } } Ok(()) } diff --git a/src/bors/handlers/refresh.rs b/src/bors/handlers/refresh.rs index ad5d057f..3d14a010 100644 --- a/src/bors/handlers/refresh.rs +++ b/src/bors/handlers/refresh.rs @@ -3,8 +3,10 @@ use std::time::Duration; use anyhow::Context; use chrono::{DateTime, Utc}; +use std::collections::BTreeMap; use crate::bors::Comment; +use crate::bors::PullRequestStatus; use crate::bors::RepositoryState; use crate::bors::handlers::trybuild::cancel_build_workflows; use crate::bors::mergeable_queue::MergeableQueueSender; @@ -97,6 +99,70 @@ pub async fn reload_repository_config(repo: Arc) -> anyhow::Res Ok(()) } +pub async fn sync_pull_requests_state( + repo: Arc, + db: Arc, +) -> anyhow::Result<()> { + let repo = repo.as_ref(); + let db = db.as_ref(); + let repo_name = repo.repository(); + // load open/draft prs from github + let nonclosed_gh_prs = repo.client.fetch_nonclosed_pull_requests().await?; + // load open/draft prs from db + let nonclosed_db_prs = db.get_nonclosed_pull_requests(repo_name).await?; + + let nonclosed_gh_prs_num = nonclosed_gh_prs + .into_iter() + .map(|pr| (pr.number, pr)) + .collect::>(); + + let nonclosed_db_prs_num = nonclosed_db_prs + .into_iter() + .map(|pr| (pr.number, pr)) + .collect::>(); + + for (pr_num, gh_pr) in &nonclosed_gh_prs_num { + let db_pr = nonclosed_db_prs_num.get(pr_num); + if let Some(db_pr) = db_pr { + if db_pr.pr_status != gh_pr.status { + // PR status changed in GitHub + tracing::debug!( + "PR {} status changed from {:?} to {:?}", + pr_num, + db_pr.pr_status, + gh_pr.status + ); + db.set_pr_status(repo_name, *pr_num, gh_pr.status).await?; + } + } else { + // Nonclosed PRs in GitHub that are either not in the DB or marked as closed + tracing::debug!("PR {} not found in open PRs in DB, upserting it", pr_num); + db.upsert_pull_request( + repo_name, + gh_pr.number, + &gh_pr.base.name, + gh_pr.mergeable_state.clone().into(), + &gh_pr.status, + ) + .await?; + } + } + // PRs that are closed in GitHub but not in the DB. In theory PR could also be merged + // but bors does the merging so it should not happen. + for pr_num in nonclosed_db_prs_num.keys() { + if !nonclosed_gh_prs_num.contains_key(pr_num) { + tracing::debug!( + "PR {} not found in open/draft prs in GitHub, closing it in DB", + pr_num + ); + db.set_pr_status(repo_name, *pr_num, PullRequestStatus::Closed) + .await?; + } + } + + Ok(()) +} + #[cfg(not(test))] fn now() -> DateTime { Utc::now() @@ -119,6 +185,7 @@ fn elapsed_time(date: DateTime) -> Duration { #[cfg(test)] mod tests { + use crate::bors::PullRequestStatus; use crate::bors::handlers::WAIT_FOR_WORKFLOW_STARTED; use crate::bors::handlers::refresh::MOCK_TIME; use crate::database::{MergeableState, OctocrabMergeableState}; @@ -139,6 +206,15 @@ mod tests { .await; } + #[sqlx::test] + async fn refresh_pr_state(pool: sqlx::PgPool) { + run_test(pool, |tester| async move { + tester.refresh_prs().await; + Ok(tester) + }) + .await; + } + fn gh_state_with_long_timeout() -> GitHubState { GitHubState::default().with_default_config( r#" @@ -246,6 +322,80 @@ timeout = 3600 .await; } + #[sqlx::test] + async fn refresh_new_pr(pool: sqlx::PgPool) { + run_test(pool, |mut tester| async move { + let pr = tester + .with_blocked_webhooks(async |tester| { + tester.open_pr(default_repo_name(), false).await + }) + .await?; + tester.refresh_prs().await; + assert_eq!( + tester + .db() + .get_pull_request(&default_repo_name(), pr.number) + .await? + .unwrap() + .pr_status, + PullRequestStatus::Open + ); + Ok(tester) + }) + .await; + } + + #[sqlx::test] + async fn refresh_pr_with_status_closed(pool: sqlx::PgPool) { + run_test(pool, |mut tester| async move { + let pr = tester.open_pr(default_repo_name(), false).await?; + tester + .with_blocked_webhooks(async |tester| { + tester.close_pr(default_repo_name(), pr.number.0).await + }) + .await?; + tester.refresh_prs().await; + assert_eq!( + tester + .db() + .get_pull_request(&default_repo_name(), pr.number) + .await? + .unwrap() + .pr_status, + PullRequestStatus::Closed + ); + Ok(tester) + }) + .await; + } + + #[sqlx::test] + async fn refresh_pr_with_status_draft(pool: sqlx::PgPool) { + run_test(pool, |mut tester| async move { + let pr = tester.open_pr(default_repo_name(), false).await?; + tester + .with_blocked_webhooks(async |tester| { + tester + .convert_to_draft(default_repo_name(), pr.number.0) + .await + }) + .await?; + + tester.refresh_prs().await; + assert_eq!( + tester + .db() + .get_pull_request(&default_repo_name(), pr.number) + .await? + .unwrap() + .pr_status, + PullRequestStatus::Draft + ); + Ok(tester) + }) + .await; + } + async fn with_mocked_time>(in_future: Duration, future: Fut) { // It is important to use this function only with a single threaded runtime, // otherwise the `MOCK_TIME` variable might get mixed up between different threads. diff --git a/src/bors/mod.rs b/src/bors/mod.rs index cedb8610..19758381 100644 --- a/src/bors/mod.rs +++ b/src/bors/mod.rs @@ -30,6 +30,9 @@ pub static WAIT_FOR_CANCEL_TIMED_OUT_BUILDS_REFRESH: TestSyncMarker = TestSyncMa #[cfg(test)] pub static WAIT_FOR_MERGEABILITY_STATUS_REFRESH: TestSyncMarker = TestSyncMarker::new(); +#[cfg(test)] +pub static WAIT_FOR_PR_STATUS_REFRESH: TestSyncMarker = TestSyncMarker::new(); + #[derive(Clone, Debug, PartialEq, Eq)] pub enum CheckSuiteStatus { Pending, @@ -59,7 +62,7 @@ impl RepositoryState { } } -#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] pub enum PullRequestStatus { Closed, Draft, diff --git a/src/database/client.rs b/src/database/client.rs index f2358d3d..63239369 100644 --- a/src/database/client.rs +++ b/src/database/client.rs @@ -10,7 +10,7 @@ use crate::github::{CommitSha, GithubRepoName}; use super::operations::{ approve_pull_request, create_build, create_pull_request, create_workflow, - delegate_pull_request, find_build, find_pr_by_build, + delegate_pull_request, find_build, find_pr_by_build, get_nonclosed_pull_requests, get_nonclosed_pull_requests_by_base_branch, get_prs_with_unknown_mergeable_state, get_pull_request, get_repository, get_running_builds, get_workflow_urls_for_build, get_workflows_for_build, set_pr_priority, set_pr_rollup, set_pr_status, unapprove_pull_request, @@ -129,6 +129,13 @@ impl PgDbClient { get_prs_with_unknown_mergeable_state(&self.pool, repo).await } + pub async fn get_nonclosed_pull_requests( + &self, + repo: &GithubRepoName, + ) -> anyhow::Result> { + get_nonclosed_pull_requests(&self.pool, repo).await + } + pub async fn create_pull_request( &self, repo: &GithubRepoName, diff --git a/src/database/operations.rs b/src/database/operations.rs index f4882261..4924c7dc 100644 --- a/src/database/operations.rs +++ b/src/database/operations.rs @@ -22,6 +22,7 @@ use super::RunId; use super::TreeState; use super::WorkflowStatus; use super::WorkflowType; +use futures::TryStreamExt; pub(crate) async fn get_pull_request( executor: impl PgExecutor<'_>, @@ -207,6 +208,47 @@ pub(crate) async fn get_nonclosed_pull_requests_by_base_branch( .await } +pub(crate) async fn get_nonclosed_pull_requests( + executor: impl PgExecutor<'_>, + repo: &GithubRepoName, +) -> anyhow::Result> { + measure_db_query("fetch_pull_requests", || async { + let mut stream = sqlx::query_as!( + PullRequestModel, + r#" + SELECT + pr.id, + pr.repository as "repository: GithubRepoName", + pr.number as "number!: i64", + ( + pr.approved_by, + pr.approved_sha + ) AS "approval_status!: ApprovalStatus", + pr.status as "pr_status: PullRequestStatus", + pr.priority, + pr.rollup as "rollup: RollupMode", + pr.delegated_permission as "delegated_permission: DelegatedPermission", + pr.base_branch, + pr.mergeable_state as "mergeable_state: MergeableState", + pr.created_at as "created_at: DateTime", + build AS "try_build: BuildModel" + FROM pull_request as pr + LEFT JOIN build ON pr.build_id = build.id + WHERE pr.repository = $1 + AND pr.status IN ('open', 'draft') + "#, + repo as &GithubRepoName + ) + .fetch(executor); + let mut prs = Vec::new(); + while let Some(pr) = stream.try_next().await? { + prs.push(pr); + } + Ok(prs) + }) + .await +} + pub(crate) async fn update_pr_mergeable_state( executor: impl PgExecutor<'_>, pr_id: i32, diff --git a/src/github/api/client.rs b/src/github/api/client.rs index cf96b4f2..a9ceebd0 100644 --- a/src/github/api/client.rs +++ b/src/github/api/client.rs @@ -11,6 +11,7 @@ use crate::github::api::base_github_html_url; use crate::github::api::operations::{MergeError, merge_branches, set_branch_to_commit}; use crate::github::{CommitSha, GithubRepoName, PullRequest, PullRequestNumber}; use crate::utils::timing::{measure_network_request, perform_network_request_with_retry}; +use futures::TryStreamExt; /// Provides access to a single app installation (repository) using the GitHub API. pub struct GithubRepositoryClient { @@ -319,6 +320,28 @@ impl GithubRepositoryClient { run_ids.map(|workflow_id| self.get_workflow_url(workflow_id)) } + pub async fn fetch_nonclosed_pull_requests(&self) -> anyhow::Result> { + let stream = self + .client + .pulls(self.repo_name.owner(), self.repo_name.name()) + .list() + .state(octocrab::params::State::Open) + .per_page(100) + .send() + .await + .map_err(|error| { + anyhow::anyhow!("Could not fetch PRs from {}: {error:?}", self.repo_name) + })? + .into_stream(&self.client); + + let mut stream = std::pin::pin!(stream); + let mut prs = Vec::new(); + while let Some(pr) = stream.try_next().await? { + prs.push(pr.into()); + } + Ok(prs) + } + fn format_pr(&self, pr: PullRequestNumber) -> String { format!("{}/{}", self.repository(), pr) } diff --git a/src/github/mod.rs b/src/github/mod.rs index 10f0cb59..61ba5924 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -152,7 +152,7 @@ impl From for PullRequest { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct PullRequestNumber(pub u64); impl From for PullRequestNumber { diff --git a/src/tests/mocks/bors.rs b/src/tests/mocks/bors.rs index 5834a067..4c33f59a 100644 --- a/src/tests/mocks/bors.rs +++ b/src/tests/mocks/bors.rs @@ -15,6 +15,7 @@ use tower::Service; use crate::bors::mergeable_queue::MergeableQueueSender; use crate::bors::{ RollupMode, WAIT_FOR_CANCEL_TIMED_OUT_BUILDS_REFRESH, WAIT_FOR_MERGEABILITY_STATUS_REFRESH, + WAIT_FOR_PR_STATUS_REFRESH, }; use crate::database::{BuildStatus, DelegatedPermission, OctocrabMergeableState, PullRequestModel}; use crate::github::api::load_repositories; @@ -104,6 +105,8 @@ pub struct BorsTester { mergeable_queue_tx: MergeableQueueSender, // Sender for bors global events global_tx: Sender, + // When this field is false, no webhooks should be generated from BorsTester methods + webhooks_active: bool, } impl BorsTester { @@ -144,6 +147,7 @@ impl BorsTester { db, mergeable_queue_tx, global_tx, + webhooks_active: true, }, bors, ) @@ -286,6 +290,15 @@ impl BorsTester { WAIT_FOR_MERGEABILITY_STATUS_REFRESH.sync().await; } + pub async fn refresh_prs(&self) { + self.global_tx + .send(BorsGlobalEvent::RefreshPullRequestState) + .await + .unwrap(); + // Wait until the refresh is fully handled + WAIT_FOR_PR_STATUS_REFRESH.sync().await; + } + /// Performs a single started/success/failure workflow event. pub async fn workflow_event(&mut self, event: WorkflowEvent) -> anyhow::Result<()> { if let Some(branch) = self @@ -376,11 +389,12 @@ impl BorsTester { ) -> anyhow::Result<()> { let pr = { let repo = self.github.get_repo(&repo_name); - let repo = repo.lock(); + let mut repo = repo.lock(); let pr = repo .pull_requests - .get(&pr_number) + .get_mut(&pr_number) .expect("PR must be opened before closing it"); + pr.close_pr(); pr.clone() }; self.send_webhook( @@ -398,11 +412,12 @@ impl BorsTester { ) -> anyhow::Result<()> { let pr = { let repo = self.github.get_repo(&repo_name); - let repo = repo.lock(); + let mut repo = repo.lock(); let pr = repo .pull_requests - .get(&pr_number) + .get_mut(&pr_number) .expect("PR must exist before being reopened"); + pr.reopen_pr(); pr.clone() }; self.send_webhook( @@ -420,11 +435,12 @@ impl BorsTester { ) -> anyhow::Result<()> { let pr = { let repo = self.github.get_repo(&repo_name); - let repo = repo.lock(); + let mut repo = repo.lock(); let pr = repo .pull_requests - .get(&pr_number) + .get_mut(&pr_number) .expect("PR must exist before being converted to draft"); + pr.convert_to_draft(); pr.clone() }; self.send_webhook( @@ -442,11 +458,12 @@ impl BorsTester { ) -> anyhow::Result<()> { let pr = { let repo = self.github.get_repo(&repo_name); - let repo = repo.lock(); + let mut repo = repo.lock(); let pr = repo .pull_requests - .get(&pr_number) + .get_mut(&pr_number) .expect("PR must exist before being ready for review"); + pr.ready_for_review(); pr.clone() }; self.send_webhook( @@ -462,16 +479,16 @@ impl BorsTester { repo_name: GithubRepoName, pr_number: u64, ) -> anyhow::Result<()> { - let mut pr = { + let pr = { let repo = self.github.get_repo(&repo_name); - let repo = repo.lock(); + let mut repo = repo.lock(); let pr = repo .pull_requests - .get(&pr_number) + .get_mut(&pr_number) .expect("PR must be opened before being merged"); + pr.merge_pr(); pr.clone() }; - pr.merge_pr(); self.send_webhook( "pull_request", GitHubPullRequestEventPayload::new(pr.clone(), "closed", None), @@ -580,6 +597,19 @@ impl BorsTester { .unwrap_or_else(|_| Err(anyhow::anyhow!("Timed out waiting for condition"))) } + /// Temporarily block sent webhooks, to emulate situation where webhooks could be lost, + /// while `func` is executing. + pub async fn with_blocked_webhooks(&mut self, func: F) -> T + where + F: AsyncFnOnce(&mut BorsTester) -> T, + { + let orig_webhooks = self.webhooks_active; + self.webhooks_active = false; + let result = func(self).await; + self.webhooks_active = orig_webhooks; + result + } + //-- Internal helper functions --/ async fn webhook_comment(&mut self, comment: Comment) -> anyhow::Result<()> { self.send_webhook( @@ -621,6 +651,10 @@ impl BorsTester { } async fn send_webhook(&mut self, event: &str, content: S) -> anyhow::Result<()> { + if !self.webhooks_active { + return Ok(()); + } + let serialized = serde_json::to_string(&content)?; let webhook = create_webhook_request(event, &serialized); let response = self diff --git a/src/tests/mocks/pull_request.rs b/src/tests/mocks/pull_request.rs index dd6040bc..1d5eea23 100644 --- a/src/tests/mocks/pull_request.rs +++ b/src/tests/mocks/pull_request.rs @@ -30,6 +30,28 @@ pub async fn mock_pull_requests( mock_server: &MockServer, ) { let repo_name = repo.lock().name.clone(); + let repo_clone = repo.clone(); + + Mock::given(method("GET")) + .and(path(format!("/repos/{repo_name}/pulls"))) + .respond_with(move |_: &Request| { + let pull_request_error = repo_clone.lock().pull_request_error; + if pull_request_error { + ResponseTemplate::new(500) + } else { + let prs = repo_clone.lock().pull_requests.clone(); + ResponseTemplate::new(200).set_body_json( + prs.values() + .into_iter() + .map(|pr| GitHubPullRequest::from(pr.clone())) + .filter(|pr| pr.closed_at.is_none()) + .collect::>(), + ) + } + }) + .mount(mock_server) + .await; + let prs = repo.lock().pull_requests.clone(); for &pr_number in prs.keys() { let repo_clone = repo.clone(); @@ -161,6 +183,8 @@ pub struct GitHubPullRequest { draft: bool, #[serde(skip_serializing_if = "Option::is_none")] merged_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + closed_at: Option>, /// The pull request number. Note that GitHub's REST API /// considers every pull-request an issue with the same number. @@ -194,6 +218,7 @@ impl From for GitHubPullRequest { sha: pr.base_branch.get_sha().to_string(), }), merged_at: pr.merged_at, + closed_at: pr.closed_at, } } } diff --git a/src/tests/mocks/repository.rs b/src/tests/mocks/repository.rs index 07b6120c..386fee91 100644 --- a/src/tests/mocks/repository.rs +++ b/src/tests/mocks/repository.rs @@ -25,7 +25,7 @@ use crate::tests::mocks::{GitHubState, TestWorkflowStatus, default_pr_number, dy use super::user::{GitHubUser, User}; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct PullRequest { pub number: PullRequestNumber, pub repo: GithubRepoName, @@ -38,6 +38,7 @@ pub struct PullRequest { pub mergeable_state: MergeableState, pub status: PullRequestStatus, pub merged_at: Option>, + pub closed_at: Option>, } impl PullRequest { @@ -58,6 +59,7 @@ impl PullRequest { PullRequestStatus::Open }, merged_at: None, + closed_at: None, } } } @@ -101,6 +103,25 @@ impl PullRequest { pub fn merge_pr(&mut self) { self.merged_at = Some(SystemTime::now().into()); + self.status = PullRequestStatus::Merged; + } + + pub fn close_pr(&mut self) { + self.closed_at = Some(SystemTime::now().into()); + self.status = PullRequestStatus::Closed; + } + + pub fn reopen_pr(&mut self) { + self.closed_at = None; + self.status = PullRequestStatus::Open; + } + + pub fn ready_for_review(&mut self) { + self.status = PullRequestStatus::Open; + } + + pub fn convert_to_draft(&mut self) { + self.status = PullRequestStatus::Draft; } } @@ -206,7 +227,7 @@ pub fn default_repo_name() -> GithubRepoName { GithubRepoName::new("rust-lang", "borstest") } -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, Debug)] pub struct Branch { name: String, sha: String,