From d996346cd767715906cc08ecb664744b630f71b4 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Wed, 18 Jun 2025 11:09:56 +0100 Subject: [PATCH 01/19] Add column `auto_build` to pull request table --- ...197542d6b73b6914670ab95984a306d311e1.json} | 46 +++++++++++++++++- ...f1021e414cdb7e218bb17916feb5dcc2e75f.json} | 46 +++++++++++++++++- ...11fb9430b377645c712c5bd1165d48577786.json} | 47 +++++++++++++++++-- ...91fd9099b9a60d9b9205d785fb90bdcee9d0.json} | 46 +++++++++++++++++- ...5e212f389fb9ed0f5f59f44524944b874bf6.json} | 46 +++++++++++++++++- ...f5fa13976b0bd65c655ecd32070a1942d4b9.json} | 47 ++++++++++++++++++- ...31_add_auto_build_to_pull_request.down.sql | 2 + ...0331_add_auto_build_to_pull_request.up.sql | 2 + src/database/mod.rs | 1 + src/database/operations.rs | 38 ++++++++++----- ...7160331_add_auto_build_to_pull_request.sql | 36 ++++++++++++++ 11 files changed, 331 insertions(+), 26 deletions(-) rename .sqlx/{query-6cbee85c41cbecc631748d10ac63fc1ea310e39edb0e93456c30f914b1d07445.json => query-662b16c5a8c1c4a708ba8f7602c3197542d6b73b6914670ab95984a306d311e1.json} (72%) rename .sqlx/{query-5dfd742dce68994fa90e7311ce0fa745fdd075768b9b2ef672ea36dafbcab620.json => query-8c40e5fa6fe2717cd20ac5dffe9bf1021e414cdb7e218bb17916feb5dcc2e75f.json} (76%) rename .sqlx/{query-9174f0f76f57b4cdfcbc3b40310643169c56424f25df617bdd84739e1b6ec5e1.json => query-97120f11a5bfe7280daa2d1393af11fb9430b377645c712c5bd1165d48577786.json} (71%) rename .sqlx/{query-460e193af9b13cc12ec9146505df3e151700fcc5ec23e54aee5e8cbe530789b2.json => query-acf2a1ece0d2b2de4944b63f091391fd9099b9a60d9b9205d785fb90bdcee9d0.json} (70%) rename .sqlx/{query-0614a7f86fae0f18c6955ee322848955149b065a69b85b662b747687799bbc66.json => query-c2e28346b7e7102194e0cf188b925e212f389fb9ed0f5f59f44524944b874bf6.json} (72%) rename .sqlx/{query-676d0b8475eebb8ccbfbd877b41b2a8b1f2f4f6d5e2662db9d5c2e02d613c769.json => query-f29db2081e4deeadf82dccc8cae7f5fa13976b0bd65c655ecd32070a1942d4b9.json} (70%) create mode 100644 migrations/20250617160331_add_auto_build_to_pull_request.down.sql create mode 100644 migrations/20250617160331_add_auto_build_to_pull_request.up.sql create mode 100644 tests/data/migrations/20250617160331_add_auto_build_to_pull_request.sql diff --git a/.sqlx/query-6cbee85c41cbecc631748d10ac63fc1ea310e39edb0e93456c30f914b1d07445.json b/.sqlx/query-662b16c5a8c1c4a708ba8f7602c3197542d6b73b6914670ab95984a306d311e1.json similarity index 72% rename from .sqlx/query-6cbee85c41cbecc631748d10ac63fc1ea310e39edb0e93456c30f914b1d07445.json rename to .sqlx/query-662b16c5a8c1c4a708ba8f7602c3197542d6b73b6914670ab95984a306d311e1.json index f25da3b4..36ad0f8c 100644 --- a/.sqlx/query-6cbee85c41cbecc631748d10ac63fc1ea310e39edb0e93456c30f914b1d07445.json +++ b/.sqlx/query-662b16c5a8c1c4a708ba8f7602c3197542d6b73b6914670ab95984a306d311e1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\n (\n pr.approved_by,\n pr.approved_sha\n ) AS \"approval_status!: ApprovalStatus\",\n pr.status as \"pr_status: PullRequestStatus\",\n pr.delegated_permission as \"delegated_permission: DelegatedPermission\",\n pr.priority,\n pr.base_branch,\n pr.mergeable_state as \"mergeable_state: MergeableState\",\n pr.rollup as \"rollup: RollupMode\",\n pr.created_at as \"created_at: DateTime\",\n build AS \"try_build: BuildModel\"\nFROM pull_request as pr\nLEFT JOIN build ON pr.build_id = build.id\nWHERE build.id = $1\n", + "query": "\nSELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\n (\n pr.approved_by,\n pr.approved_sha\n ) AS \"approval_status!: ApprovalStatus\",\n pr.status as \"pr_status: PullRequestStatus\",\n pr.delegated_permission as \"delegated_permission: DelegatedPermission\",\n pr.priority,\n pr.base_branch,\n pr.mergeable_state as \"mergeable_state: MergeableState\",\n pr.rollup as \"rollup: RollupMode\",\n pr.created_at as \"created_at: DateTime\",\n try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\nFROM pull_request as pr\nLEFT JOIN build AS try_build ON pr.build_id = try_build.id\nLEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\nWHERE try_build.id = $1\n", "describe": { "columns": [ { @@ -113,6 +113,47 @@ } } } + }, + { + "ordinal": 15, + "name": "auto_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": { @@ -135,8 +176,9 @@ false, true, false, + null, null ] }, - "hash": "6cbee85c41cbecc631748d10ac63fc1ea310e39edb0e93456c30f914b1d07445" + "hash": "662b16c5a8c1c4a708ba8f7602c3197542d6b73b6914670ab95984a306d311e1" } diff --git a/.sqlx/query-5dfd742dce68994fa90e7311ce0fa745fdd075768b9b2ef672ea36dafbcab620.json b/.sqlx/query-8c40e5fa6fe2717cd20ac5dffe9bf1021e414cdb7e218bb17916feb5dcc2e75f.json similarity index 76% rename from .sqlx/query-5dfd742dce68994fa90e7311ce0fa745fdd075768b9b2ef672ea36dafbcab620.json rename to .sqlx/query-8c40e5fa6fe2717cd20ac5dffe9bf1021e414cdb7e218bb17916feb5dcc2e75f.json index c852669c..0546da8e 100644 --- a/.sqlx/query-5dfd742dce68994fa90e7311ce0fa745fdd075768b9b2ef672ea36dafbcab620.json +++ b/.sqlx/query-8c40e5fa6fe2717cd20ac5dffe9bf1021e414cdb7e218bb17916feb5dcc2e75f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n WITH upserted_pr AS (\n INSERT INTO pull_request (repository, number, title, author, assignees, base_branch, mergeable_state, status)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (repository, number)\n DO UPDATE SET\n title = $3,\n author = $4,\n assignees = $5,\n base_branch = $6,\n mergeable_state = $7,\n status = $8\n RETURNING *\n )\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 upserted_pr as pr\n LEFT JOIN build ON pr.build_id = build.id\n ", + "query": "\n WITH upserted_pr AS (\n INSERT INTO pull_request (repository, number, title, author, assignees, base_branch, mergeable_state, status)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (repository, number)\n DO UPDATE SET\n title = $3,\n author = $4,\n assignees = $5,\n base_branch = $6,\n mergeable_state = $7,\n status = $8\n RETURNING *\n )\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM upserted_pr as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n ", "describe": { "columns": [ { @@ -113,6 +113,47 @@ } } } + }, + { + "ordinal": 15, + "name": "auto_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": { @@ -142,8 +183,9 @@ false, false, false, + true, true ] }, - "hash": "5dfd742dce68994fa90e7311ce0fa745fdd075768b9b2ef672ea36dafbcab620" + "hash": "8c40e5fa6fe2717cd20ac5dffe9bf1021e414cdb7e218bb17916feb5dcc2e75f" } diff --git a/.sqlx/query-9174f0f76f57b4cdfcbc3b40310643169c56424f25df617bdd84739e1b6ec5e1.json b/.sqlx/query-97120f11a5bfe7280daa2d1393af11fb9430b377645c712c5bd1165d48577786.json similarity index 71% rename from .sqlx/query-9174f0f76f57b4cdfcbc3b40310643169c56424f25df617bdd84739e1b6ec5e1.json rename to .sqlx/query-97120f11a5bfe7280daa2d1393af11fb9430b377645c712c5bd1165d48577786.json index dacc31f2..77359526 100644 --- a/.sqlx/query-9174f0f76f57b4cdfcbc3b40310643169c56424f25df617bdd84739e1b6ec5e1.json +++ b/.sqlx/query-97120f11a5bfe7280daa2d1393af11fb9430b377645c712c5bd1165d48577786.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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.base_branch = $2\n AND pr.status IN ('open', 'draft')\n ", + "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n WHERE pr.repository = $1\n AND pr.status IN ('open', 'draft')\n ", "describe": { "columns": [ { @@ -113,11 +113,51 @@ } } } + }, + { + "ordinal": 15, + "name": "auto_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", "Text" ] }, @@ -136,8 +176,9 @@ false, false, false, + null, null ] }, - "hash": "9174f0f76f57b4cdfcbc3b40310643169c56424f25df617bdd84739e1b6ec5e1" + "hash": "97120f11a5bfe7280daa2d1393af11fb9430b377645c712c5bd1165d48577786" } diff --git a/.sqlx/query-460e193af9b13cc12ec9146505df3e151700fcc5ec23e54aee5e8cbe530789b2.json b/.sqlx/query-acf2a1ece0d2b2de4944b63f091391fd9099b9a60d9b9205d785fb90bdcee9d0.json similarity index 70% rename from .sqlx/query-460e193af9b13cc12ec9146505df3e151700fcc5ec23e54aee5e8cbe530789b2.json rename to .sqlx/query-acf2a1ece0d2b2de4944b63f091391fd9099b9a60d9b9205d785fb90bdcee9d0.json index ce38be28..ccaa7469 100644 --- a/.sqlx/query-460e193af9b13cc12ec9146505df3e151700fcc5ec23e54aee5e8cbe530789b2.json +++ b/.sqlx/query-acf2a1ece0d2b2de4944b63f091391fd9099b9a60d9b9205d785fb90bdcee9d0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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.mergeable_state = 'unknown'\n AND pr.status IN ('open', 'draft')\n ", + "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n WHERE pr.repository = $1\n AND pr.mergeable_state = 'unknown'\n AND pr.status IN ('open', 'draft')\n ", "describe": { "columns": [ { @@ -113,6 +113,47 @@ } } } + }, + { + "ordinal": 15, + "name": "auto_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": { @@ -135,8 +176,9 @@ false, false, false, + null, null ] }, - "hash": "460e193af9b13cc12ec9146505df3e151700fcc5ec23e54aee5e8cbe530789b2" + "hash": "acf2a1ece0d2b2de4944b63f091391fd9099b9a60d9b9205d785fb90bdcee9d0" } diff --git a/.sqlx/query-0614a7f86fae0f18c6955ee322848955149b065a69b85b662b747687799bbc66.json b/.sqlx/query-c2e28346b7e7102194e0cf188b925e212f389fb9ed0f5f59f44524944b874bf6.json similarity index 72% rename from .sqlx/query-0614a7f86fae0f18c6955ee322848955149b065a69b85b662b747687799bbc66.json rename to .sqlx/query-c2e28346b7e7102194e0cf188b925e212f389fb9ed0f5f59f44524944b874bf6.json index 7e1a3b67..1704e394 100644 --- a/.sqlx/query-0614a7f86fae0f18c6955ee322848955149b065a69b85b662b747687799bbc66.json +++ b/.sqlx/query-c2e28346b7e7102194e0cf188b925e212f389fb9ed0f5f59f44524944b874bf6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 AND\n pr.number = $2\n ", + "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n WHERE pr.repository = $1 AND\n pr.number = $2\n ", "describe": { "columns": [ { @@ -113,6 +113,47 @@ } } } + }, + { + "ordinal": 15, + "name": "auto_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": { @@ -136,8 +177,9 @@ false, false, false, + null, null ] }, - "hash": "0614a7f86fae0f18c6955ee322848955149b065a69b85b662b747687799bbc66" + "hash": "c2e28346b7e7102194e0cf188b925e212f389fb9ed0f5f59f44524944b874bf6" } diff --git a/.sqlx/query-676d0b8475eebb8ccbfbd877b41b2a8b1f2f4f6d5e2662db9d5c2e02d613c769.json b/.sqlx/query-f29db2081e4deeadf82dccc8cae7f5fa13976b0bd65c655ecd32070a1942d4b9.json similarity index 70% rename from .sqlx/query-676d0b8475eebb8ccbfbd877b41b2a8b1f2f4f6d5e2662db9d5c2e02d613c769.json rename to .sqlx/query-f29db2081e4deeadf82dccc8cae7f5fa13976b0bd65c655ecd32070a1942d4b9.json index 52fc4d53..fa5d8094 100644 --- a/.sqlx/query-676d0b8475eebb8ccbfbd877b41b2a8b1f2f4f6d5e2662db9d5c2e02d613c769.json +++ b/.sqlx/query-f29db2081e4deeadf82dccc8cae7f5fa13976b0bd65c655ecd32070a1942d4b9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 ", + "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n WHERE pr.repository = $1\n AND pr.base_branch = $2\n AND pr.status IN ('open', 'draft')\n ", "describe": { "columns": [ { @@ -113,10 +113,52 @@ } } } + }, + { + "ordinal": 15, + "name": "auto_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", "Text" ] }, @@ -135,8 +177,9 @@ false, false, false, + null, null ] }, - "hash": "676d0b8475eebb8ccbfbd877b41b2a8b1f2f4f6d5e2662db9d5c2e02d613c769" + "hash": "f29db2081e4deeadf82dccc8cae7f5fa13976b0bd65c655ecd32070a1942d4b9" } diff --git a/migrations/20250617160331_add_auto_build_to_pull_request.down.sql b/migrations/20250617160331_add_auto_build_to_pull_request.down.sql new file mode 100644 index 00000000..47fd9146 --- /dev/null +++ b/migrations/20250617160331_add_auto_build_to_pull_request.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +ALTER TABLE pull_request DROP COLUMN auto_build_id; diff --git a/migrations/20250617160331_add_auto_build_to_pull_request.up.sql b/migrations/20250617160331_add_auto_build_to_pull_request.up.sql new file mode 100644 index 00000000..0c81d679 --- /dev/null +++ b/migrations/20250617160331_add_auto_build_to_pull_request.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +ALTER TABLE pull_request ADD COLUMN auto_build_id INTEGER REFERENCES build(id); diff --git a/src/database/mod.rs b/src/database/mod.rs index faef042a..906880ab 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -329,6 +329,7 @@ pub struct PullRequestModel { pub priority: Option, pub rollup: Option, pub try_build: Option, + pub auto_build: Option, pub created_at: DateTime, } diff --git a/src/database/operations.rs b/src/database/operations.rs index 0d27d8b3..3fd7cb3a 100644 --- a/src/database/operations.rs +++ b/src/database/operations.rs @@ -53,9 +53,11 @@ pub(crate) async fn get_pull_request( pr.base_branch, pr.mergeable_state as "mergeable_state: MergeableState", pr.created_at as "created_at: DateTime", - build AS "try_build: BuildModel" + try_build AS "try_build: BuildModel", + auto_build AS "auto_build: BuildModel" FROM pull_request as pr - LEFT JOIN build ON pr.build_id = build.id + LEFT JOIN build AS try_build ON pr.build_id = try_build.id + LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id WHERE pr.repository = $1 AND pr.number = $2 "#, @@ -163,9 +165,11 @@ pub(crate) async fn upsert_pull_request( pr.base_branch, pr.mergeable_state as "mergeable_state: MergeableState", pr.created_at as "created_at: DateTime", - build AS "try_build: BuildModel" + try_build AS "try_build: BuildModel", + auto_build AS "auto_build: BuildModel" FROM upserted_pr as pr - LEFT JOIN build ON pr.build_id = build.id + LEFT JOIN build AS try_build ON pr.build_id = try_build.id + LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id "#, repo as &GithubRepoName, params.pr_number.0 as i32, @@ -212,9 +216,11 @@ pub(crate) async fn get_nonclosed_pull_requests_by_base_branch( pr.base_branch, pr.mergeable_state as "mergeable_state: MergeableState", pr.created_at as "created_at: DateTime", - build AS "try_build: BuildModel" + try_build AS "try_build: BuildModel", + auto_build AS "auto_build: BuildModel" FROM pull_request as pr - LEFT JOIN build ON pr.build_id = build.id + LEFT JOIN build AS try_build ON pr.build_id = try_build.id + LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id WHERE pr.repository = $1 AND pr.base_branch = $2 AND pr.status IN ('open', 'draft') @@ -256,9 +262,11 @@ pub(crate) async fn get_nonclosed_pull_requests( pr.base_branch, pr.mergeable_state as "mergeable_state: MergeableState", pr.created_at as "created_at: DateTime", - build AS "try_build: BuildModel" + try_build AS "try_build: BuildModel", + auto_build AS "auto_build: BuildModel" FROM pull_request as pr - LEFT JOIN build ON pr.build_id = build.id + LEFT JOIN build AS try_build ON pr.build_id = try_build.id + LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id WHERE pr.repository = $1 AND pr.status IN ('open', 'draft') "#, @@ -318,9 +326,11 @@ pub(crate) async fn get_prs_with_unknown_mergeable_state( pr.base_branch, pr.mergeable_state as "mergeable_state: MergeableState", pr.created_at as "created_at: DateTime", - build AS "try_build: BuildModel" + try_build AS "try_build: BuildModel", + auto_build AS "auto_build: BuildModel" FROM pull_request as pr - LEFT JOIN build ON pr.build_id = build.id + LEFT JOIN build AS try_build ON pr.build_id = try_build.id + LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id WHERE pr.repository = $1 AND pr.mergeable_state = 'unknown' AND pr.status IN ('open', 'draft') @@ -470,10 +480,12 @@ SELECT pr.mergeable_state as "mergeable_state: MergeableState", pr.rollup as "rollup: RollupMode", pr.created_at as "created_at: DateTime", - build AS "try_build: BuildModel" + try_build AS "try_build: BuildModel", + auto_build AS "auto_build: BuildModel" FROM pull_request as pr -LEFT JOIN build ON pr.build_id = build.id -WHERE build.id = $1 +LEFT JOIN build AS try_build ON pr.build_id = try_build.id +LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id +WHERE try_build.id = $1 "#, build_id ) diff --git a/tests/data/migrations/20250617160331_add_auto_build_to_pull_request.sql b/tests/data/migrations/20250617160331_add_auto_build_to_pull_request.sql new file mode 100644 index 00000000..216b014e --- /dev/null +++ b/tests/data/migrations/20250617160331_add_auto_build_to_pull_request.sql @@ -0,0 +1,36 @@ +INSERT INTO + build (repository, branch, commit_sha, status, parent) +VALUES + ( + 'rust-lang/bors', + 'automation/bors/merge', + 'f2a8b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0', + 'success', + 'a7ec24743ca724dd4b164b3a76d29d0da9573617' + ), + ( + 'rust-lang/cargo', + 'automation/bors/merge', + '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a1', + 'pending', + 'b3f987c12ee248ef21d37b59a40b17e93fac7c8a' + ), + ( + 'rust-lang/rust', + 'automation/bors/merge', + '9f8e7d6c5b4a3928374658291047382910473829', + 'failure', + '4ee5a1bfc10bc49f30a8f527557ac4a93a2b9d66' + ); + +UPDATE pull_request +SET + auto_build_id = 4 +WHERE + id = 1; + +UPDATE pull_request +SET + auto_build_id = 5 +WHERE + id = 2; From cbc5ecc4bc4fc99b966e9d5f8e9b6949a8446209 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Thu, 19 Jun 2025 12:30:13 +0100 Subject: [PATCH 02/19] Add merge queue background task --- ...1134df3ac7818af0c085ac432eb52874a62a2.json | 15 ++ ...60cdb1159c442845ec85999d4c931f336fead.json | 185 +++++++++++++++ src/bors/handlers/mod.rs | 11 +- src/bors/merge_queue.rs | 210 ++++++++++++++++++ src/bors/mod.rs | 1 + src/database/client.rs | 28 ++- src/database/operations.rs | 93 ++++++++ src/github/api/client.rs | 35 +++ src/github/server.rs | 48 +++- src/tests/mocks/bors.rs | 5 + 10 files changed, 623 insertions(+), 8 deletions(-) create mode 100644 .sqlx/query-3ae0ba7ce98f0a68b47df52a9181134df3ac7818af0c085ac432eb52874a62a2.json create mode 100644 .sqlx/query-b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead.json create mode 100644 src/bors/merge_queue.rs diff --git a/.sqlx/query-3ae0ba7ce98f0a68b47df52a9181134df3ac7818af0c085ac432eb52874a62a2.json b/.sqlx/query-3ae0ba7ce98f0a68b47df52a9181134df3ac7818af0c085ac432eb52874a62a2.json new file mode 100644 index 00000000..d4166e22 --- /dev/null +++ b/.sqlx/query-3ae0ba7ce98f0a68b47df52a9181134df3ac7818af0c085ac432eb52874a62a2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE pull_request SET auto_build_id = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "3ae0ba7ce98f0a68b47df52a9181134df3ac7818af0c085ac432eb52874a62a2" +} diff --git a/.sqlx/query-b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead.json b/.sqlx/query-b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead.json new file mode 100644 index 00000000..2f411a2e --- /dev/null +++ b/.sqlx/query-b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead.json @@ -0,0 +1,185 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n WHERE pr.repository = $1\n AND pr.status = 'open'\n AND pr.approved_by IS NOT NULL\n AND pr.mergeable_state = 'mergeable'\n -- Tree closure check (if tree_priority is set)\n AND ($2::int IS NULL OR pr.priority >= $2)\n ORDER BY\n -- 1. Build status priority\n CASE\n WHEN auto_build.status = 'pending' THEN 1\n WHEN pr.approved_by IS NOT NULL AND auto_build.id IS NULL THEN 2\n WHEN auto_build.id IS NULL THEN 3\n WHEN auto_build.status IN ('cancelled', 'timeouted') THEN 4\n WHEN auto_build.status = 'failure' THEN 5\n WHEN auto_build.status = 'success' THEN 6\n ELSE -1\n END,\n -- 2. Priority\n -COALESCE(pr.priority, 0),\n -- 3. Rollup (capped at -1)\n GREATEST(\n CASE\n WHEN pr.rollup = 'always' THEN -2\n WHEN pr.rollup = 'maybe' THEN 0\n WHEN pr.rollup = 'iffy' THEN -1\n WHEN pr.rollup = 'never' THEN 1\n ELSE 0\n END,\n -1\n ),\n -- 4. PR number (older PRs first)\n pr.number\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": "title", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "author", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "assignees: Assignees", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "approval_status!: ApprovalStatus", + "type_info": "Record" + }, + { + "ordinal": 7, + "name": "pr_status: PullRequestStatus", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 9, + "name": "rollup: RollupMode", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "delegated_permission: DelegatedPermission", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "base_branch", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "mergeable_state: MergeableState", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "created_at: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "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" + ] + ] + } + } + } + }, + { + "ordinal": 15, + "name": "auto_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", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + null, + false, + true, + true, + true, + false, + false, + false, + null, + null + ] + }, + "hash": "b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead" +} diff --git a/src/bors/handlers/mod.rs b/src/bors/handlers/mod.rs index 92885fb4..ec3cdf48 100644 --- a/src/bors/handlers/mod.rs +++ b/src/bors/handlers/mod.rs @@ -19,6 +19,7 @@ use crate::bors::handlers::trybuild::{TRY_BRANCH_NAME, command_try_build, comman use crate::bors::handlers::workflow::{ handle_check_suite_completed, handle_workflow_completed, handle_workflow_started, }; +use crate::bors::merge_queue::MergeQueueEvent; use crate::bors::{BorsContext, Comment, RepositoryState}; use crate::database::{DelegatedPermission, PullRequestModel}; use crate::github::{GithubUser, PullRequest, PullRequestNumber}; @@ -33,6 +34,7 @@ use pr_events::{ }; use refresh::sync_pull_requests_state; use review::{command_delegate, command_set_priority, command_set_rollup, command_undelegate}; +use tokio::sync::mpsc; use tracing::Instrument; use super::mergeable_queue::MergeableQueueSender; @@ -51,6 +53,7 @@ mod workflow; pub async fn handle_bors_repository_event( event: BorsRepositoryEvent, ctx: Arc, + merge_queue_tx: mpsc::Sender, mergeable_queue_tx: MergeableQueueSender, ) -> anyhow::Result<()> { let db = Arc::clone(&ctx.db); @@ -81,7 +84,7 @@ pub async fn handle_bors_repository_event( author = comment.author.username ); let pr_number = comment.pr_number; - if let Err(error) = handle_comment(Arc::clone(&repo), db, ctx, comment) + if let Err(error) = handle_comment(Arc::clone(&repo), db, ctx, merge_queue_tx, comment) .instrument(span.clone()) .await { @@ -347,6 +350,7 @@ async fn handle_comment( repo: Arc, database: Arc, ctx: Arc, + merge_queue_tx: mpsc::Sender, comment: PullRequestComment, ) -> anyhow::Result<()> { let pr_number = comment.pr_number; @@ -514,6 +518,11 @@ async fn handle_comment( } } } + + if let Err(err) = merge_queue_tx.send(()).await { + tracing::error!("Failed to send merge queue message: {err}"); + } + Ok(()) } diff --git a/src/bors/merge_queue.rs b/src/bors/merge_queue.rs new file mode 100644 index 00000000..497345c1 --- /dev/null +++ b/src/bors/merge_queue.rs @@ -0,0 +1,210 @@ +use anyhow::anyhow; +use octocrab::models::StatusState; + +use std::sync::Arc; + +use crate::{ + BorsContext, + bors::{Comment, RepositoryState, comment::merge_conflict_comment}, + database::{BuildStatus, PullRequestModel}, + github::{CommitSha, MergeError, api::client::GithubRepositoryClient}, +}; + +/// Branch used for preparing merge commits. +/// This branch should not run CI checks. +const AUTO_MERGE_BRANCH_NAME: &str = "automation/bors/auto-merge"; + +/// Branch where CI checks run for merge builds. +/// This branch should run CI checks. +const AUTO_BRANCH_NAME: &str = "automation/bors/auto"; + +pub type MergeQueueEvent = (); + +enum MergeResult { + Success(CommitSha), + Conflict, +} + +pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { + let repos: Vec> = + ctx.repositories.read().unwrap().values().cloned().collect(); + + for repo in repos { + let repo_name = repo.repository(); + let repo_db = match ctx.db.repo_db(repo_name).await? { + Some(repo) => repo, + None => { + tracing::error!("Repository {} not found", repo_name); + continue; + } + }; + let priority = repo_db.tree_state.priority(); + // Fetch all eligible PRs from the database (pending PRs come first). + let prs = ctx + .db + .get_merge_queue_pull_requests(repo_name, priority) + .await?; + + for pr in prs { + let pr_num = pr.number; + + if let Some(auto_build) = &pr.auto_build { + match auto_build.status { + // Build in progress - stop queue to prevent starting simultaneous auto-builds. + BuildStatus::Pending => { + tracing::debug!( + "PR {repo_name}/{pr_num} has a pending build - blocking queue" + ); + break; + } + // Build completed successfully - attempt to merge into base branch. + BuildStatus::Success => { + // Push the merge commit to the base branch. + repo.client + .set_branch_to_sha( + &pr.base_branch, + &CommitSha(auto_build.commit_sha.clone()), + ) + .await?; + } + BuildStatus::Failure | BuildStatus::Cancelled | BuildStatus::Timeouted => { + tracing::debug!( + "PR {} has a failed build ({}), skipping", + pr.number, + auto_build.status + ); + continue; + } + } + } + + // No build exists for this PR - start a new merge build. + match start_auto_build(&repo, &ctx, pr).await { + Ok(true) => { + tracing::info!("Starting merge build for PR {pr_num}"); + // We can only have one PR being built at a time - block the queue. + break; + } + Ok(false) => { + // Failed due to issue with the PR (e.g. merge conflicts). + tracing::debug!("Failed to start merge build for PR {pr_num}"); + continue; + } + Err(error) => { + // Unexpected error - the PR will remain in the queue for a retry. + tracing::error!("Error starting merge build for PR {pr_num}: {:?}", error); + continue; + } + } + } + } + + Ok(()) +} + +/// Starts a new auto build for a pull request. +async fn start_auto_build( + repo: &Arc, + ctx: &Arc, + pr: PullRequestModel, +) -> anyhow::Result { + let client = &repo.client; + + let gh_pr = client.get_pull_request(pr.number).await?; + let base_sha = client.get_branch_sha(&pr.base_branch).await?; + + // Format: "Auto merge of #123 - user:branch, r=approver\n\nTitle\n\nDescription" + let merge_message = format!( + "Auto merge of #{} - {}, r={}\n\n{}\n\n{}", + pr.number, + gh_pr.head_label, + pr.approver().unwrap_or(""), + pr.title, + gh_pr.message + ); + + match attempt_merge(&repo.client, &gh_pr.head.sha, &base_sha, &merge_message).await? { + MergeResult::Success(merge_sha) => { + // 1. Push merge commit to auto branch where CI runs + client + .set_branch_to_sha(AUTO_BRANCH_NAME, &merge_sha) + .await?; + + // 2. Record the build in the database + ctx.db + .attach_try_build( + &pr, + AUTO_BRANCH_NAME.to_string(), + merge_sha.clone(), + base_sha, + ) + .await?; + + // 3. Post status comment + let comment = format!( + ":hourglass: Testing commit {} with merge {}...", + gh_pr.head.sha, merge_sha + ); + client + .post_comment(pr.number, Comment::new(comment)) + .await?; + + // 4. Update GitHub status + let desc = format!( + "Testing commit {} with merge {}...", + gh_pr.head.sha, merge_sha + ); + client + .create_commit_status( + &gh_pr.head.sha, + StatusState::Pending, + None, + Some(&desc), + Some("bors"), + ) + .await?; + + return Ok(true); + } + MergeResult::Conflict => { + repo.client + .post_comment(pr.number, merge_conflict_comment(&gh_pr.head.name)) + .await?; + return Ok(false); + } + } +} + +/// Attempt to merge two branches. +async fn attempt_merge( + client: &GithubRepositoryClient, + head_sha: &CommitSha, + base_sha: &CommitSha, + merge_message: &str, +) -> anyhow::Result { + tracing::debug!("Attempting to merge with base SHA {base_sha}"); + + // Reset auto-merge branch to base branch + client + .set_branch_to_sha(AUTO_MERGE_BRANCH_NAME, base_sha) + .await + .map_err(|error| anyhow!("Cannot set try merge branch to {}: {error:?}", base_sha.0))?; + + // then merge PR commit into auto-merge branch. + match client + .merge_branches(AUTO_MERGE_BRANCH_NAME, head_sha, merge_message) + .await + { + Ok(merge_sha) => { + tracing::debug!("Merge successful, SHA: {merge_sha}"); + + Ok(MergeResult::Success(merge_sha)) + } + Err(MergeError::Conflict) => { + tracing::warn!("Merge conflict"); + + Ok(MergeResult::Conflict) + } + Err(error) => Err(error.into()), + } +} diff --git a/src/bors/mod.rs b/src/bors/mod.rs index e94b6fba..39c3413c 100644 --- a/src/bors/mod.rs +++ b/src/bors/mod.rs @@ -22,6 +22,7 @@ pub mod comment; mod context; pub mod event; mod handlers; +pub mod merge_queue; pub mod mergeable_queue; #[cfg(test)] diff --git a/src/database/client.rs b/src/database/client.rs index 008ebf43..f9e623ae 100644 --- a/src/database/client.rs +++ b/src/database/client.rs @@ -1,6 +1,7 @@ use sqlx::PgPool; use crate::bors::{PullRequestStatus, RollupMode}; +use crate::database::operations::get_merge_queue_pull_requests; use crate::database::{ BuildModel, BuildStatus, PullRequestModel, RepoModel, TreeState, WorkflowModel, WorkflowStatus, WorkflowType, @@ -16,8 +17,8 @@ use super::operations::{ get_workflow_urls_for_build, get_workflows_for_build, insert_repo_if_not_exists, set_pr_assignees, set_pr_priority, set_pr_rollup, set_pr_status, unapprove_pull_request, undelegate_pull_request, update_build_status, update_mergeable_states_by_base_branch, - update_pr_build_id, update_pr_mergeable_state, update_workflow_status, upsert_pull_request, - upsert_repository, + update_pr_auto_build_id, update_pr_build_id, update_pr_mergeable_state, update_workflow_status, + upsert_pull_request, upsert_repository, }; use super::{ApprovalInfo, DelegatedPermission, MergeableState, RunId, UpsertPullRequestParams}; @@ -190,6 +191,21 @@ impl PgDbClient { Ok(()) } + pub async fn attach_auto_build( + &self, + pr: &PullRequestModel, + branch: String, + commit_sha: CommitSha, + parent: CommitSha, + ) -> anyhow::Result<()> { + let mut tx = self.pool.begin().await?; + let build_id = + create_build(&mut *tx, &pr.repository, &branch, &commit_sha, &parent).await?; + update_pr_auto_build_id(&mut *tx, pr.id, build_id).await?; + tx.commit().await?; + Ok(()) + } + pub async fn find_build( &self, repo: &GithubRepoName, @@ -296,4 +312,12 @@ impl PgDbClient { ) -> anyhow::Result<()> { upsert_repository(&self.pool, repo, tree_state).await } + + pub async fn get_merge_queue_pull_requests( + &self, + repo: &GithubRepoName, + tree_priority: Option, + ) -> anyhow::Result> { + get_merge_queue_pull_requests(&self.pool, repo, tree_priority.map(|p| p as i32)).await + } } diff --git a/src/database/operations.rs b/src/database/operations.rs index 3fd7cb3a..c7e5f876 100644 --- a/src/database/operations.rs +++ b/src/database/operations.rs @@ -515,6 +515,24 @@ pub(crate) async fn update_pr_build_id( .await } +pub(crate) async fn update_pr_auto_build_id( + executor: impl PgExecutor<'_>, + pr_id: i32, + build_id: i32, +) -> anyhow::Result<()> { + measure_db_query("update_pr_auto_build_id", || async { + sqlx::query!( + "UPDATE pull_request SET auto_build_id = $1 WHERE id = $2", + build_id, + pr_id + ) + .execute(executor) + .await?; + Ok(()) + }) + .await +} + pub(crate) async fn create_build( executor: impl PgExecutor<'_>, repo: &GithubRepoName, @@ -941,3 +959,78 @@ pub(crate) async fn upsert_repository( }) .await } + +pub(crate) async fn get_merge_queue_pull_requests( + executor: impl PgExecutor<'_>, + repo: &GithubRepoName, + tree_priority: Option, +) -> anyhow::Result> { + measure_db_query("get_merge_queue_pull_requests", || async { + let records = sqlx::query_as!( + PullRequestModel, + r#" + SELECT + pr.id, + pr.repository as "repository: GithubRepoName", + pr.number as "number!: i64", + pr.title, + pr.author, + pr.assignees as "assignees: Assignees", + ( + 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", + try_build AS "try_build: BuildModel", + auto_build AS "auto_build: BuildModel" + FROM pull_request as pr + LEFT JOIN build AS try_build ON pr.build_id = try_build.id + LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id + WHERE pr.repository = $1 + AND pr.status = 'open' + AND pr.approved_by IS NOT NULL + AND pr.mergeable_state = 'mergeable' + -- Tree closure check (if tree_priority is set) + AND ($2::int IS NULL OR pr.priority >= $2) + ORDER BY + -- 1. Build status priority + CASE + WHEN auto_build.status = 'pending' THEN 1 + WHEN pr.approved_by IS NOT NULL AND auto_build.id IS NULL THEN 2 + WHEN auto_build.id IS NULL THEN 3 + WHEN auto_build.status IN ('cancelled', 'timeouted') THEN 4 + WHEN auto_build.status = 'failure' THEN 5 + WHEN auto_build.status = 'success' THEN 6 + ELSE -1 + END, + -- 2. Priority + -COALESCE(pr.priority, 0), + -- 3. Rollup (capped at -1) + GREATEST( + CASE + WHEN pr.rollup = 'always' THEN -2 + WHEN pr.rollup = 'maybe' THEN 0 + WHEN pr.rollup = 'iffy' THEN -1 + WHEN pr.rollup = 'never' THEN 1 + ELSE 0 + END, + -1 + ), + -- 4. PR number (older PRs first) + pr.number + "#, + repo as &GithubRepoName, + tree_priority + ) + .fetch_all(executor) + .await?; + Ok(records) + }) + .await +} diff --git a/src/github/api/client.rs b/src/github/api/client.rs index a9ceebd0..88172e9a 100644 --- a/src/github/api/client.rs +++ b/src/github/api/client.rs @@ -1,5 +1,6 @@ use anyhow::Context; use octocrab::Octocrab; +pub use octocrab::models::StatusState; use octocrab::models::{App, Repository}; use tracing::log; @@ -342,6 +343,40 @@ impl GithubRepositoryClient { Ok(prs) } + /// Create a commit status for the given SHA. + pub async fn create_commit_status( + &self, + sha: &CommitSha, + state: StatusState, + target_url: Option<&str>, + description: Option<&str>, + context: Option<&str>, + ) -> anyhow::Result<()> { + perform_network_request_with_retry("create_commit_status", || async { + let repos = self + .client + .repos(&self.repository().owner, &self.repository().name); + let mut builder = repos.create_status(sha.0.clone(), state); + + if let Some(url) = target_url { + builder = builder.target(url.to_string()); + } + if let Some(desc) = description { + builder = builder.description(desc.to_string()); + } + if let Some(ctx) = context { + builder = builder.context(ctx.to_string()); + } + + builder + .send() + .await + .context("Cannot create commit status")?; + Ok(()) + }) + .await? + } + fn format_pr(&self, pr: PullRequestNumber) -> String { format!("{}/{}", self.repository(), pr) } diff --git a/src/github/server.rs b/src/github/server.rs index 241d1b48..85a37534 100644 --- a/src/github/server.rs +++ b/src/github/server.rs @@ -1,4 +1,5 @@ use crate::bors::event::BorsEvent; +use crate::bors::merge_queue::{MergeQueueEvent, handle_merge_queue}; use crate::bors::mergeable_queue::{ MergeableQueueReceiver, MergeableQueueSender, create_mergeable_queue, handle_mergeable_queue_item, @@ -184,6 +185,7 @@ pub async fn github_webhook_handler( pub struct BorsProcess { pub repository_tx: mpsc::Sender, pub global_tx: mpsc::Sender, + pub merge_queue_tx: mpsc::Sender, pub mergeable_queue_tx: MergeableQueueSender, pub bors_process: Pin + Send>>, } @@ -197,9 +199,11 @@ pub fn create_bors_process( ) -> BorsProcess { let (repository_tx, repository_rx) = mpsc::channel::(1024); let (global_tx, global_rx) = mpsc::channel::(1024); + let (merge_queue_tx, merge_queue_rx) = mpsc::channel::<()>(128); let (mergeable_queue_tx, mergeable_queue_rx) = create_mergeable_queue(); let mq_tx = mergeable_queue_tx.clone(); + let merge_queue_tx_clone = merge_queue_tx.clone(); let service = async move { let ctx = Arc::new(ctx); @@ -211,8 +215,14 @@ pub fn create_bors_process( #[cfg(test)] { tokio::join!( - consume_repository_events(ctx.clone(), repository_rx, mq_tx.clone()), + consume_repository_events( + ctx.clone(), + repository_rx, + merge_queue_tx, + mq_tx.clone() + ), consume_global_events(ctx.clone(), global_rx, mq_tx, gh_client, team_api), + consume_merge_queue(ctx.clone(), merge_queue_rx), consume_mergeable_queue(ctx, mergeable_queue_rx) ); } @@ -222,12 +232,15 @@ pub fn create_bors_process( #[cfg(not(test))] { tokio::select! { - _ = consume_repository_events(ctx.clone(), repository_rx, mq_tx.clone()) => { + _ = consume_repository_events(ctx.clone(), repository_rx, merge_queue_tx, mq_tx.clone()) => { tracing::error!("Repository event handling process has ended"); } _ = consume_global_events(ctx.clone(), global_rx, mq_tx, gh_client, team_api) => { tracing::error!("Global event handling process has ended"); } + _ = consume_merge_queue(ctx.clone(), merge_queue_rx) => { + tracing::error!("Merge queue handling process has ended"); + } _ = consume_mergeable_queue(ctx, mergeable_queue_rx) => { tracing::error!("Mergeable queue handling process has ended") } @@ -238,6 +251,7 @@ pub fn create_bors_process( BorsProcess { repository_tx, global_tx, + merge_queue_tx: merge_queue_tx_clone, mergeable_queue_tx, bors_process: Box::pin(service), } @@ -246,17 +260,20 @@ pub fn create_bors_process( async fn consume_repository_events( ctx: Arc, mut repository_rx: mpsc::Receiver, + merge_queue_tx: mpsc::Sender, mergeable_queue_tx: MergeableQueueSender, ) { while let Some(event) = repository_rx.recv().await { let ctx = ctx.clone(); let mergeable_queue_tx = mergeable_queue_tx.clone(); + let merge_queue_tx = merge_queue_tx.clone(); let span = tracing::info_span!("RepositoryEvent"); tracing::debug!("Received repository event: {event:#?}"); - if let Err(error) = handle_bors_repository_event(event, ctx, mergeable_queue_tx) - .instrument(span.clone()) - .await + if let Err(error) = + handle_bors_repository_event(event, ctx, merge_queue_tx, mergeable_queue_tx) + .instrument(span.clone()) + .await { handle_root_error(span, error); } @@ -286,6 +303,27 @@ async fn consume_global_events( } } +async fn consume_merge_queue( + ctx: Arc, + mut merge_queue_rx: mpsc::Receiver, +) { + while let Some(_) = merge_queue_rx.recv().await { + // Drain any extras that have arrived. + while let Ok(()) = merge_queue_rx.try_recv() { + // We can do this as we know the state of the queue has changed + // and this single run should capture those changes. + } + + let ctx = ctx.clone(); + + let span = tracing::info_span!("MergeQueue"); + tracing::debug!("Processing merge queue"); + if let Err(error) = handle_merge_queue(ctx).instrument(span.clone()).await { + handle_root_error(span, error); + } + } +} + async fn consume_mergeable_queue( ctx: Arc, mergeable_queue_rx: MergeableQueueReceiver, diff --git a/src/tests/mocks/bors.rs b/src/tests/mocks/bors.rs index 450120cb..bcaa4211 100644 --- a/src/tests/mocks/bors.rs +++ b/src/tests/mocks/bors.rs @@ -16,6 +16,7 @@ use super::pull_request::{ GitHubPullRequestEventPayload, GitHubPushEventPayload, PullRequestChangeEvent, }; use super::repository::PullRequest; +use crate::bors::merge_queue::MergeQueueEvent; use crate::bors::mergeable_queue::MergeableQueueSender; use crate::bors::{ RollupMode, WAIT_FOR_CANCEL_TIMED_OUT_BUILDS_REFRESH, WAIT_FOR_MERGEABILITY_STATUS_REFRESH, @@ -102,6 +103,7 @@ pub struct BorsTester { http_mock: ExternalHttpMock, github: GitHubState, db: Arc, + merge_queue_tx: Sender, mergeable_queue_tx: MergeableQueueSender, // Sender for bors global events global_tx: Sender, @@ -133,6 +135,7 @@ impl BorsTester { repository_tx, global_tx, mergeable_queue_tx, + merge_queue_tx, bors_process, } = create_bors_process(ctx, mock.github_client(), mock.team_api_client()); @@ -151,6 +154,7 @@ impl BorsTester { http_mock: mock, github, db, + merge_queue_tx, mergeable_queue_tx, global_tx, webhooks_active: true, @@ -771,6 +775,7 @@ impl BorsTester { // Make sure that the event channel senders are closed drop(self.app); drop(self.global_tx); + drop(self.merge_queue_tx); self.mergeable_queue_tx.shutdown(); // Wait until all events are handled in the bors service bors.await.unwrap(); From daa4de4152fb65ba99dd2641e8e98eec88bcee7b Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Thu, 19 Jun 2025 17:24:58 +0100 Subject: [PATCH 03/19] Update auto build status on check suite completed webhook --- src/bors/comment.rs | 40 ++++++++++ src/bors/handlers/mod.rs | 10 +-- src/bors/handlers/workflow.rs | 140 +++++++++++++++++++++++++++++++++- src/bors/merge_queue.rs | 61 ++++++++------- src/config.rs | 6 ++ src/github/labels.rs | 3 + 6 files changed, 225 insertions(+), 35 deletions(-) diff --git a/src/bors/comment.rs b/src/bors/comment.rs index 441c97e5..616a64aa 100644 --- a/src/bors/comment.rs +++ b/src/bors/comment.rs @@ -181,3 +181,43 @@ fn list_workflows_status(workflows: &[WorkflowModel]) -> String { .collect::>() .join("\n") } + +pub fn auto_build_started_comment(head_sha: &CommitSha, merge_sha: &CommitSha) -> Comment { + Comment::new(format!( + ":hourglass: Testing commit {} with merge {}...", + head_sha, merge_sha + )) +} + +pub fn auto_build_succeeded_comment( + workflows: &[WorkflowModel], + approved_by: &str, + merge_sha: &CommitSha, + base_ref: &str, +) -> Comment { + let urls = workflows + .iter() + .map(|w| format!("[{}]({})", w.name, w.url)) + .collect::>() + .join(", "); + + Comment::new(format!( + ":sunny: Test successful - {}\nApproved by: {}\nPushing {} to {}...", + urls, approved_by, merge_sha, base_ref + )) +} + +pub fn auto_build_failed_comment(workflows: &[WorkflowModel]) -> Comment { + let failed_workflow = workflows + .iter() + .find(|w| w.status == WorkflowStatus::Failure); + + if let Some(workflow) = failed_workflow { + Comment::new(format!( + ":broken_heart: Test failed - [{}]({})", + workflow.name, workflow.url + )) + } else { + Comment::new(":broken_heart: Test failed".to_string()) + } +} diff --git a/src/bors/handlers/mod.rs b/src/bors/handlers/mod.rs index ec3cdf48..58868241 100644 --- a/src/bors/handlers/mod.rs +++ b/src/bors/handlers/mod.rs @@ -19,7 +19,7 @@ use crate::bors::handlers::trybuild::{TRY_BRANCH_NAME, command_try_build, comman use crate::bors::handlers::workflow::{ handle_check_suite_completed, handle_workflow_completed, handle_workflow_started, }; -use crate::bors::merge_queue::MergeQueueEvent; +use crate::bors::merge_queue::{AUTO_BRANCH_NAME, MergeQueueEvent}; use crate::bors::{BorsContext, Comment, RepositoryState}; use crate::database::{DelegatedPermission, PullRequestModel}; use crate::github::{GithubUser, PullRequest, PullRequestNumber}; @@ -41,7 +41,7 @@ use super::mergeable_queue::MergeableQueueSender; mod help; mod info; -mod labels; +pub mod labels; mod ping; mod pr_events; mod refresh; @@ -119,7 +119,7 @@ pub async fn handle_bors_repository_event( repo = payload.repository.to_string(), id = payload.run_id.into_inner() ); - handle_workflow_completed(repo, db, payload) + handle_workflow_completed(repo, db, merge_queue_tx, payload) .instrument(span.clone()) .await?; } @@ -128,7 +128,7 @@ pub async fn handle_bors_repository_event( "Check suite completed", repo = payload.repository.to_string(), ); - handle_check_suite_completed(repo, db, payload) + handle_check_suite_completed(repo, db, merge_queue_tx, payload) .instrument(span.clone()) .await?; } @@ -558,7 +558,7 @@ async fn reload_repos( /// Is this branch interesting for the bot? fn is_bors_observed_branch(branch: &str) -> bool { - branch == TRY_BRANCH_NAME + matches!(branch, TRY_BRANCH_NAME | AUTO_BRANCH_NAME) } /// Deny permission for a request. diff --git a/src/bors/handlers/workflow.rs b/src/bors/handlers/workflow.rs index 09adb4b4..dcd76996 100644 --- a/src/bors/handlers/workflow.rs +++ b/src/bors/handlers/workflow.rs @@ -1,13 +1,26 @@ use std::sync::Arc; use std::time::Duration; +use octocrab::models::StatusState; +use tokio::sync::mpsc; + use crate::PgDbClient; use crate::bors::CheckSuiteStatus; +use crate::bors::Comment; +use crate::bors::PullRequestStatus; use crate::bors::RepositoryState; +use crate::bors::comment::auto_build_failed_comment; +use crate::bors::comment::auto_build_succeeded_comment; use crate::bors::comment::{try_build_succeeded_comment, workflow_failed_comment}; use crate::bors::event::{CheckSuiteCompleted, WorkflowCompleted, WorkflowStarted}; +use crate::bors::handlers::TRY_BRANCH_NAME; use crate::bors::handlers::is_bors_observed_branch; use crate::bors::handlers::labels::handle_label_trigger; +use crate::bors::merge_queue::AUTO_BRANCH_NAME; +use crate::bors::merge_queue::MergeQueueEvent; +use crate::database::BuildModel; +use crate::database::PullRequestModel; +use crate::database::WorkflowModel; use crate::database::{BuildStatus, WorkflowStatus}; use crate::github::LabelTrigger; @@ -62,6 +75,7 @@ pub(super) async fn handle_workflow_started( pub(super) async fn handle_workflow_completed( repo: Arc, db: Arc, + merge_queue_tx: mpsc::Sender, mut payload: WorkflowCompleted, ) -> anyhow::Result<()> { if !is_bors_observed_branch(&payload.branch) { @@ -95,12 +109,13 @@ pub(super) async fn handle_workflow_completed( branch: payload.branch, commit_sha: payload.commit_sha, }; - try_complete_build(repo.as_ref(), db.as_ref(), event).await + try_complete_build(repo.as_ref(), db.as_ref(), merge_queue_tx, event).await } pub(super) async fn handle_check_suite_completed( repo: Arc, db: Arc, + merge_queue_tx: mpsc::Sender, payload: CheckSuiteCompleted, ) -> anyhow::Result<()> { if !is_bors_observed_branch(&payload.branch) { @@ -112,13 +127,15 @@ pub(super) async fn handle_check_suite_completed( payload.branch, payload.commit_sha ); - try_complete_build(repo.as_ref(), db.as_ref(), payload).await + + try_complete_build(repo.as_ref(), db.as_ref(), merge_queue_tx, payload).await } /// Try to complete a pending build. async fn try_complete_build( repo: &RepositoryState, db: &PgDbClient, + merge_queue_tx: mpsc::Sender, payload: CheckSuiteCompleted, ) -> anyhow::Result<()> { if !is_bors_observed_branch(&payload.branch) { @@ -184,13 +201,46 @@ async fn try_complete_build( return Ok(()); } + match payload.branch.as_str() { + TRY_BRANCH_NAME => { + complete_try_build(db, repo, pr, build, workflows, has_failure, payload).await? + } + AUTO_BRANCH_NAME => { + complete_auto_build( + db, + repo, + pr, + build, + workflows, + has_failure, + merge_queue_tx, + payload, + ) + .await? + } + _ => {} + } + + Ok(()) +} + +/// Complete the try build workflow. +async fn complete_try_build( + db: &PgDbClient, + repo: &RepositoryState, + pr: PullRequestModel, + build: BuildModel, + workflows: Vec, + has_failure: bool, + payload: CheckSuiteCompleted, +) -> anyhow::Result<()> { let (status, trigger) = if has_failure { (BuildStatus::Failure, LabelTrigger::TryBuildFailed) } else { (BuildStatus::Success, LabelTrigger::TryBuildSucceeded) }; - db.update_build_status(&build, status).await?; + db.update_build_status(&build, status).await?; handle_label_trigger(repo, pr.number, trigger).await?; let message = if !has_failure { @@ -205,6 +255,90 @@ async fn try_complete_build( Ok(()) } +/// Complete the auto build workflow. +async fn complete_auto_build( + db: &PgDbClient, + repo: &RepositoryState, + pr: PullRequestModel, + build: BuildModel, + workflows: Vec, + has_failure: bool, + merge_queue_tx: mpsc::Sender, + payload: CheckSuiteCompleted, +) -> anyhow::Result<()> { + let (status, trigger) = if has_failure { + (BuildStatus::Failure, LabelTrigger::AutoBuildFailed) + } else { + (BuildStatus::Success, LabelTrigger::AutoBuildSucceeded) + }; + + db.update_build_status(&build, status).await?; + handle_label_trigger(repo, pr.number, trigger).await?; + + let merge_succeeded = if !has_failure { + match repo + .client + .set_branch_to_sha(&pr.base_branch, &payload.commit_sha) + .await + { + Ok(_) => { + db.set_pr_status(&pr.repository, pr.number, PullRequestStatus::Merged) + .await?; + true + } + Err(e) => { + tracing::error!("Failed to push to base branch: {:?}", e); + db.update_build_status(&build, BuildStatus::Failure).await?; + false + } + } + } else { + false + }; + + let message = if has_failure { + tracing::info!("Auto build failed"); + auto_build_failed_comment(&workflows) + } else if merge_succeeded { + tracing::info!("Auto build succeeded and merged"); + auto_build_succeeded_comment( + &workflows, + pr.approver().unwrap_or(""), + &payload.commit_sha, + &pr.base_branch, + ) + } else { + // TODO: Deal with failed push to branch properly. + Comment::new(":x: Test successful but failed to push to base branch".to_string()) + }; + + repo.client.post_comment(pr.number, message).await?; + + // Update GitHub status + let (gh_status, gh_desc) = if has_failure || !merge_succeeded { + (StatusState::Failure, "Build failed") + } else { + (StatusState::Success, "Build succeeded") + }; + + let gh_pr = repo.client.get_pull_request(pr.number).await?; + repo.client + .create_commit_status( + &gh_pr.head.sha, + gh_status, + None, + Some(gh_desc), + Some("bors"), + ) + .await?; + + if let Err(err) = merge_queue_tx.send(()).await { + tracing::error!("Failed to trigger merge queue: {err}"); + } + + Ok(()) +} + #[cfg(test)] mod tests { use crate::bors::handlers::trybuild::TRY_BRANCH_NAME; diff --git a/src/bors/merge_queue.rs b/src/bors/merge_queue.rs index 497345c1..dbaa3717 100644 --- a/src/bors/merge_queue.rs +++ b/src/bors/merge_queue.rs @@ -5,18 +5,22 @@ use std::sync::Arc; use crate::{ BorsContext, - bors::{Comment, RepositoryState, comment::merge_conflict_comment}, + bors::{ + RepositoryState, + comment::{auto_build_started_comment, merge_conflict_comment}, + handlers::labels::handle_label_trigger, + }, database::{BuildStatus, PullRequestModel}, - github::{CommitSha, MergeError, api::client::GithubRepositoryClient}, + github::{CommitSha, LabelTrigger, MergeError, api::client::GithubRepositoryClient}, }; /// Branch used for preparing merge commits. /// This branch should not run CI checks. -const AUTO_MERGE_BRANCH_NAME: &str = "automation/bors/auto-merge"; +pub(super) const AUTO_MERGE_BRANCH_NAME: &str = "automation/bors/auto-merge"; /// Branch where CI checks run for merge builds. /// This branch should run CI checks. -const AUTO_BRANCH_NAME: &str = "automation/bors/auto"; +pub(super) const AUTO_BRANCH_NAME: &str = "automation/bors/auto"; pub type MergeQueueEvent = (); @@ -57,15 +61,15 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { ); break; } - // Build completed successfully - attempt to merge into base branch. + // Successful builds should already be merged via webhooks, + // so we handle stuck PRs caused by GitHub failures or crashes here. BuildStatus::Success => { - // Push the merge commit to the base branch. - repo.client - .set_branch_to_sha( - &pr.base_branch, - &CommitSha(auto_build.commit_sha.clone()), - ) - .await?; + // TODO: Implement recovery mechanism. + tracing::warn!( + "PR {} has a successful build, skipping (should already be merged)", + pr.number + ); + continue; } BuildStatus::Failure | BuildStatus::Cancelled | BuildStatus::Timeouted => { tracing::debug!( @@ -113,8 +117,7 @@ async fn start_auto_build( let gh_pr = client.get_pull_request(pr.number).await?; let base_sha = client.get_branch_sha(&pr.base_branch).await?; - // Format: "Auto merge of #123 - user:branch, r=approver\n\nTitle\n\nDescription" - let merge_message = format!( + let auto_merge_commit_message = format!( "Auto merge of #{} - {}, r={}\n\n{}\n\n{}", pr.number, gh_pr.head_label, @@ -123,14 +126,22 @@ async fn start_auto_build( gh_pr.message ); - match attempt_merge(&repo.client, &gh_pr.head.sha, &base_sha, &merge_message).await? { + // 1. Attempt to merge the PR and base branch + match attempt_merge( + &repo.client, + &gh_pr.head.sha, + &base_sha, + &auto_merge_commit_message, + ) + .await? + { MergeResult::Success(merge_sha) => { - // 1. Push merge commit to auto branch where CI runs + // 2. Push merge commit to auto branch where CI runs client .set_branch_to_sha(AUTO_BRANCH_NAME, &merge_sha) .await?; - // 2. Record the build in the database + // 3. Record the build in the database ctx.db .attach_try_build( &pr, @@ -140,16 +151,12 @@ async fn start_auto_build( ) .await?; - // 3. Post status comment - let comment = format!( - ":hourglass: Testing commit {} with merge {}...", - gh_pr.head.sha, merge_sha - ); - client - .post_comment(pr.number, Comment::new(comment)) - .await?; + // 4. Update label and post status comment + handle_label_trigger(repo, pr.number, LabelTrigger::AutoBuildStarted).await?; + let comment = auto_build_started_comment(&gh_pr.head.sha, &merge_sha); + client.post_comment(pr.number, comment).await?; - // 4. Update GitHub status + // 5. Update GitHub status let desc = format!( "Testing commit {} with merge {}...", gh_pr.head.sha, merge_sha @@ -175,7 +182,7 @@ async fn start_auto_build( } } -/// Attempt to merge two branches. +/// Attempts to merge the given head SHA into `AUTO_MERGE_BRANCH_NAME` at the given base SHA. async fn attempt_merge( client: &GithubRepositoryClient, head_sha: &CommitSha, diff --git a/src/config.rs b/src/config.rs index ad31448a..81fc5ca8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -58,6 +58,9 @@ where Try, TrySucceed, TryFailed, + Auto, + AutoSucceed, + AutoFailed, } impl From for LabelTrigger { @@ -68,6 +71,9 @@ where Trigger::Try => LabelTrigger::TryBuildStarted, Trigger::TrySucceed => LabelTrigger::TryBuildSucceeded, Trigger::TryFailed => LabelTrigger::TryBuildFailed, + Trigger::Auto => LabelTrigger::AutoBuildStarted, + Trigger::AutoSucceed => LabelTrigger::AutoBuildSucceeded, + Trigger::AutoFailed => LabelTrigger::AutoBuildFailed, } } } diff --git a/src/github/labels.rs b/src/github/labels.rs index 337bb33b..f8d8ca18 100644 --- a/src/github/labels.rs +++ b/src/github/labels.rs @@ -6,6 +6,9 @@ pub enum LabelTrigger { TryBuildStarted, TryBuildSucceeded, TryBuildFailed, + AutoBuildStarted, + AutoBuildSucceeded, + AutoBuildFailed, } #[derive(Debug, Eq, PartialEq)] From 2fc2b82654110576ca55fdb1baa6bf1637467c16 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Thu, 19 Jun 2025 20:24:57 +0100 Subject: [PATCH 04/19] Add labels to rust-bors.example.toml --- rust-bors.example.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rust-bors.example.toml b/rust-bors.example.toml index 61a14252..e4348688 100644 --- a/rust-bors.example.toml +++ b/rust-bors.example.toml @@ -8,9 +8,15 @@ timeout = 3600 # - try: Try build has started # - try_succeed: Try build has finished # - try_failed: Try build has failed +# - auto: Auto build has started +# - auto_succeed: Auto build has finished +# - auto_failed: Auto build has failed # (Optional) [labels] approve = ["+approved"] try = ["+foo", "-bar"] try_succeed = ["+foobar", "+foo", "+baz"] try_failed = [] +auto = ["+merge-testing"] +auto_succeed = ["+merged", "-merge-testing"] +auto_failed = ["+merge-failed", "-merge-testing"] From fce2793509ce458e95c661fcafbd4935f2e84584 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Thu, 19 Jun 2025 22:10:36 +0100 Subject: [PATCH 05/19] Cleanup --- src/bors/handlers/workflow.rs | 67 +++++++++++++++-------------------- src/bors/merge_queue.rs | 37 ++++++++++--------- src/github/server.rs | 2 +- 3 files changed, 50 insertions(+), 56 deletions(-) diff --git a/src/bors/handlers/workflow.rs b/src/bors/handlers/workflow.rs index dcd76996..23f759ac 100644 --- a/src/bors/handlers/workflow.rs +++ b/src/bors/handlers/workflow.rs @@ -6,7 +6,6 @@ use tokio::sync::mpsc; use crate::PgDbClient; use crate::bors::CheckSuiteStatus; -use crate::bors::Comment; use crate::bors::PullRequestStatus; use crate::bors::RepositoryState; use crate::bors::comment::auto_build_failed_comment; @@ -201,9 +200,21 @@ async fn try_complete_build( return Ok(()); } - match payload.branch.as_str() { + let branch = payload.branch.as_str(); + let (status, trigger) = match (branch, has_failure) { + (TRY_BRANCH_NAME, true) => (BuildStatus::Failure, LabelTrigger::TryBuildFailed), + (TRY_BRANCH_NAME, false) => (BuildStatus::Success, LabelTrigger::TryBuildSucceeded), + (AUTO_BRANCH_NAME, true) => (BuildStatus::Failure, LabelTrigger::AutoBuildFailed), + (AUTO_BRANCH_NAME, false) => (BuildStatus::Success, LabelTrigger::AutoBuildSucceeded), + _ => unreachable!(), + }; + + db.update_build_status(&build, status).await?; + handle_label_trigger(repo, pr.number, trigger).await?; + + match branch { TRY_BRANCH_NAME => { - complete_try_build(db, repo, pr, build, workflows, has_failure, payload).await? + complete_try_build(repo, pr, build, workflows, has_failure, payload).await? } AUTO_BRANCH_NAME => { complete_auto_build( @@ -226,7 +237,6 @@ async fn try_complete_build( /// Complete the try build workflow. async fn complete_try_build( - db: &PgDbClient, repo: &RepositoryState, pr: PullRequestModel, build: BuildModel, @@ -234,15 +244,6 @@ async fn complete_try_build( has_failure: bool, payload: CheckSuiteCompleted, ) -> anyhow::Result<()> { - let (status, trigger) = if has_failure { - (BuildStatus::Failure, LabelTrigger::TryBuildFailed) - } else { - (BuildStatus::Success, LabelTrigger::TryBuildSucceeded) - }; - - db.update_build_status(&build, status).await?; - handle_label_trigger(repo, pr.number, trigger).await?; - let message = if !has_failure { tracing::info!("Workflow succeeded"); try_build_succeeded_comment(&workflows, payload.commit_sha, &build) @@ -256,6 +257,7 @@ async fn complete_try_build( } /// Complete the auto build workflow. +#[allow(clippy::too_many_arguments)] async fn complete_auto_build( db: &PgDbClient, repo: &RepositoryState, @@ -266,41 +268,33 @@ async fn complete_auto_build( merge_queue_tx: mpsc::Sender, payload: CheckSuiteCompleted, ) -> anyhow::Result<()> { - let (status, trigger) = if has_failure { - (BuildStatus::Failure, LabelTrigger::AutoBuildFailed) + let (build_succeeded, merge_succeeded) = if has_failure { + tracing::info!("Auto build failed"); + (false, false) } else { - (BuildStatus::Success, LabelTrigger::AutoBuildSucceeded) - }; - - db.update_build_status(&build, status).await?; - handle_label_trigger(repo, pr.number, trigger).await?; - - let merge_succeeded = if !has_failure { + // Update base branch to point to the merged and tested commit match repo .client .set_branch_to_sha(&pr.base_branch, &payload.commit_sha) .await { - Ok(_) => { + Ok(()) => { + tracing::info!("Auto build succeeded and merged"); db.set_pr_status(&pr.repository, pr.number, PullRequestStatus::Merged) .await?; - true + (true, true) } Err(e) => { tracing::error!("Failed to push to base branch: {:?}", e); db.update_build_status(&build, BuildStatus::Failure).await?; - false + (true, false) } } - } else { - false }; - let message = if has_failure { - tracing::info!("Auto build failed"); + let message = if !build_succeeded { auto_build_failed_comment(&workflows) } else if merge_succeeded { - tracing::info!("Auto build succeeded and merged"); auto_build_succeeded_comment( &workflows, pr.approver().unwrap_or(""), @@ -308,17 +302,14 @@ async fn complete_auto_build( &pr.base_branch, ) } else { - // TODO: Deal with failed push to branch properly. - Comment::new(":x: Test successful but failed to push to base branch".to_string()) + todo!("Deal with failed branch push"); }; - repo.client.post_comment(pr.number, message).await?; - // Update GitHub status - let (gh_status, gh_desc) = if has_failure || !merge_succeeded { - (StatusState::Failure, "Build failed") - } else { + let (gh_status, gh_desc) = if build_succeeded && merge_succeeded { (StatusState::Success, "Build succeeded") + } else { + (StatusState::Failure, "Build failed") }; let gh_pr = repo.client.get_pull_request(pr.number).await?; @@ -333,7 +324,7 @@ async fn complete_auto_build( .await?; if let Err(err) = merge_queue_tx.send(()).await { - tracing::error!("Failed to trigger merge queue: {err}"); + tracing::error!("Failed to invoke merge queue: {err}"); } Ok(()) diff --git a/src/bors/merge_queue.rs b/src/bors/merge_queue.rs index dbaa3717..210e52f5 100644 --- a/src/bors/merge_queue.rs +++ b/src/bors/merge_queue.rs @@ -14,7 +14,7 @@ use crate::{ github::{CommitSha, LabelTrigger, MergeError, api::client::GithubRepositoryClient}, }; -/// Branch used for preparing merge commits. +/// Branch used for performing merge operations. /// This branch should not run CI checks. pub(super) const AUTO_MERGE_BRANCH_NAME: &str = "automation/bors/auto-merge"; @@ -43,7 +43,9 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { } }; let priority = repo_db.tree_state.priority(); - // Fetch all eligible PRs from the database (pending PRs come first). + // Fetch all eligible PRs from the database. + // Pending PRs come first - this is important as we make sure to block the queue to + // prevent starting simultaneous auto-builds. let prs = ctx .db .get_merge_queue_pull_requests(repo_name, priority) @@ -54,7 +56,7 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { if let Some(auto_build) = &pr.auto_build { match auto_build.status { - // Build in progress - stop queue to prevent starting simultaneous auto-builds. + // Build in progress - stop queue. We can only have one PR built at a time. BuildStatus::Pending => { tracing::debug!( "PR {repo_name}/{pr_num} has a pending build - blocking queue" @@ -64,12 +66,11 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { // Successful builds should already be merged via webhooks, // so we handle stuck PRs caused by GitHub failures or crashes here. BuildStatus::Success => { - // TODO: Implement recovery mechanism. tracing::warn!( "PR {} has a successful build, skipping (should already be merged)", pr.number ); - continue; + todo!("Implement stuck PR recovery mechanism"); } BuildStatus::Failure | BuildStatus::Cancelled | BuildStatus::Timeouted => { tracing::debug!( @@ -82,7 +83,7 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { } } - // No build exists for this PR - start a new merge build. + // No build exists for this PR - try to start a new merge build. match start_auto_build(&repo, &ctx, pr).await { Ok(true) => { tracing::info!("Starting merge build for PR {pr_num}"); @@ -95,7 +96,7 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { continue; } Err(error) => { - // Unexpected error - the PR will remain in the queue for a retry. + // Unexpected error - the PR will remain in the "queue" for a retry. tracing::error!("Error starting merge build for PR {pr_num}: {:?}", error); continue; } @@ -126,7 +127,7 @@ async fn start_auto_build( gh_pr.message ); - // 1. Attempt to merge the PR and base branch + // 1. Merge PR head with base branch on `AUTO_MERGE_BRANCH_NAME` match attempt_merge( &repo.client, &gh_pr.head.sha, @@ -136,7 +137,7 @@ async fn start_auto_build( .await? { MergeResult::Success(merge_sha) => { - // 2. Push merge commit to auto branch where CI runs + // 2. Push merge commit to `AUTO_BRANCH_NAME` where CI runs client .set_branch_to_sha(AUTO_BRANCH_NAME, &merge_sha) .await?; @@ -151,12 +152,14 @@ async fn start_auto_build( ) .await?; - // 4. Update label and post status comment + // 4. Update label handle_label_trigger(repo, pr.number, LabelTrigger::AutoBuildStarted).await?; + + // 5. Post status comment let comment = auto_build_started_comment(&gh_pr.head.sha, &merge_sha); client.post_comment(pr.number, comment).await?; - // 5. Update GitHub status + // 6. Set GitHub commit status to pending on PR head let desc = format!( "Testing commit {} with merge {}...", gh_pr.head.sha, merge_sha @@ -171,18 +174,18 @@ async fn start_auto_build( ) .await?; - return Ok(true); + Ok(true) } MergeResult::Conflict => { repo.client .post_comment(pr.number, merge_conflict_comment(&gh_pr.head.name)) .await?; - return Ok(false); + Ok(false) } } } -/// Attempts to merge the given head SHA into `AUTO_MERGE_BRANCH_NAME` at the given base SHA. +/// Attempts to merge the given head SHA with base SHA via `AUTO_MERGE_BRANCH_NAME`. async fn attempt_merge( client: &GithubRepositoryClient, head_sha: &CommitSha, @@ -191,13 +194,13 @@ async fn attempt_merge( ) -> anyhow::Result { tracing::debug!("Attempting to merge with base SHA {base_sha}"); - // Reset auto-merge branch to base branch + // Reset auto merge branch to point to base branch client .set_branch_to_sha(AUTO_MERGE_BRANCH_NAME, base_sha) .await - .map_err(|error| anyhow!("Cannot set try merge branch to {}: {error:?}", base_sha.0))?; + .map_err(|error| anyhow!("Cannot set auto merge branch to {}: {error:?}", base_sha.0))?; - // then merge PR commit into auto-merge branch. + // then merge PR head commit into auto merge branch. match client .merge_branches(AUTO_MERGE_BRANCH_NAME, head_sha, merge_message) .await diff --git a/src/github/server.rs b/src/github/server.rs index 85a37534..5154294e 100644 --- a/src/github/server.rs +++ b/src/github/server.rs @@ -307,7 +307,7 @@ async fn consume_merge_queue( ctx: Arc, mut merge_queue_rx: mpsc::Receiver, ) { - while let Some(_) = merge_queue_rx.recv().await { + while merge_queue_rx.recv().await.is_some() { // Drain any extras that have arrived. while let Ok(()) = merge_queue_rx.try_recv() { // We can do this as we know the state of the queue has changed From 6544ae5f945de37e37b7f8ade43835b5985634a0 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Thu, 19 Jun 2025 22:12:32 +0100 Subject: [PATCH 06/19] Improve comment accuracy --- src/bors/handlers/workflow.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bors/handlers/workflow.rs b/src/bors/handlers/workflow.rs index 23f759ac..614b9148 100644 --- a/src/bors/handlers/workflow.rs +++ b/src/bors/handlers/workflow.rs @@ -272,7 +272,7 @@ async fn complete_auto_build( tracing::info!("Auto build failed"); (false, false) } else { - // Update base branch to point to the merged and tested commit + // Update base branch to point to the tested commit match repo .client .set_branch_to_sha(&pr.base_branch, &payload.commit_sha) From eaf5c9b621861d7d35b9ae42b46434fd61c01cc8 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Thu, 19 Jun 2025 22:26:34 +0100 Subject: [PATCH 07/19] Cancel active auto build on PR push --- src/bors/handlers/pr_events.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/bors/handlers/pr_events.rs b/src/bors/handlers/pr_events.rs index 2d8759bd..4ffc3a7a 100644 --- a/src/bors/handlers/pr_events.rs +++ b/src/bors/handlers/pr_events.rs @@ -5,9 +5,10 @@ use crate::bors::event::{ PullRequestReopened, PullRequestUnassigned, PushToBranch, }; use crate::bors::handlers::labels::handle_label_trigger; +use crate::bors::handlers::trybuild::cancel_build_workflows; use crate::bors::mergeable_queue::MergeableQueueSender; use crate::bors::{Comment, PullRequestStatus, RepositoryState}; -use crate::database::MergeableState; +use crate::database::{BuildStatus, MergeableState, PullRequestModel}; use crate::github::{CommitSha, LabelTrigger, PullRequestNumber}; use std::sync::Arc; @@ -53,6 +54,8 @@ pub(super) async fn handle_push_to_pull_request( mergeable_queue.enqueue(repo_state.repository().clone(), pr_number); + cancel_active_auto_build_for_pr(&repo_state, &db, &pr_model).await?; + if !pr_model.is_approved() { return Ok(()); } @@ -261,6 +264,34 @@ PR will need to be re-approved."#, .await } +async fn cancel_active_auto_build_for_pr( + repo_state: &RepositoryState, + db: &PgDbClient, + pr_model: &PullRequestModel, +) -> anyhow::Result<()> { + let Some(auto_build) = &pr_model.auto_build else { + return Ok(()); + }; + + if auto_build.status != BuildStatus::Pending { + return Ok(()); + } + + tracing::info!( + "Cancelling auto build for PR {} due to push", + pr_model.number + ); + + if let Err(error) = cancel_build_workflows(&repo_state.client, db, auto_build).await { + tracing::error!( + "Could not cancel auto build workflows for PR {}: {error:?}", + pr_model.number + ); + } + + Ok(()) +} + #[cfg(test)] mod tests { use crate::bors::PullRequestStatus; From 3054c68e4e912ee514eb702772b3392569909df5 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 12:00:43 +0100 Subject: [PATCH 08/19] Move queue sorting logic to rust --- ...c3f90fc7d1fa3d2793d01c1e664b1e77a711.json} | 4 +- src/bors/merge_queue.rs | 10 +-- src/database/client.rs | 21 +++---- src/database/operations.rs | 30 +-------- src/github/server.rs | 2 + src/utils/mod.rs | 1 + src/utils/sort_queue.rs | 61 +++++++++++++++++++ 7 files changed, 83 insertions(+), 46 deletions(-) rename .sqlx/{query-b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead.json => query-9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711.json} (80%) create mode 100644 src/utils/sort_queue.rs diff --git a/.sqlx/query-b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead.json b/.sqlx/query-9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711.json similarity index 80% rename from .sqlx/query-b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead.json rename to .sqlx/query-9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711.json index 2f411a2e..137e8251 100644 --- a/.sqlx/query-b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead.json +++ b/.sqlx/query-9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n WHERE pr.repository = $1\n AND pr.status = 'open'\n AND pr.approved_by IS NOT NULL\n AND pr.mergeable_state = 'mergeable'\n -- Tree closure check (if tree_priority is set)\n AND ($2::int IS NULL OR pr.priority >= $2)\n ORDER BY\n -- 1. Build status priority\n CASE\n WHEN auto_build.status = 'pending' THEN 1\n WHEN pr.approved_by IS NOT NULL AND auto_build.id IS NULL THEN 2\n WHEN auto_build.id IS NULL THEN 3\n WHEN auto_build.status IN ('cancelled', 'timeouted') THEN 4\n WHEN auto_build.status = 'failure' THEN 5\n WHEN auto_build.status = 'success' THEN 6\n ELSE -1\n END,\n -- 2. Priority\n -COALESCE(pr.priority, 0),\n -- 3. Rollup (capped at -1)\n GREATEST(\n CASE\n WHEN pr.rollup = 'always' THEN -2\n WHEN pr.rollup = 'maybe' THEN 0\n WHEN pr.rollup = 'iffy' THEN -1\n WHEN pr.rollup = 'never' THEN 1\n ELSE 0\n END,\n -1\n ),\n -- 4. PR number (older PRs first)\n pr.number\n ", + "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n WHERE pr.repository = $1\n AND pr.status = 'open'\n AND pr.approved_by IS NOT NULL\n AND pr.mergeable_state = 'mergeable'\n -- Tree closure check (if tree_priority is set)\n AND ($2::int IS NULL OR pr.priority >= $2)\n ", "describe": { "columns": [ { @@ -181,5 +181,5 @@ null ] }, - "hash": "b91830d4dc1282f83e7497add4460cdb1159c442845ec85999d4c931f336fead" + "hash": "9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711" } diff --git a/src/bors/merge_queue.rs b/src/bors/merge_queue.rs index 210e52f5..d9aeaa25 100644 --- a/src/bors/merge_queue.rs +++ b/src/bors/merge_queue.rs @@ -12,6 +12,7 @@ use crate::{ }, database::{BuildStatus, PullRequestModel}, github::{CommitSha, LabelTrigger, MergeError, api::client::GithubRepositoryClient}, + utils::sort_queue::sort_queue_prs, }; /// Branch used for performing merge operations. @@ -43,13 +44,12 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { } }; let priority = repo_db.tree_state.priority(); - // Fetch all eligible PRs from the database. + let prs = ctx.db.get_merge_queue_prs(repo_name, priority).await?; + + // Sort PRs according to merge queue priority rules. // Pending PRs come first - this is important as we make sure to block the queue to // prevent starting simultaneous auto-builds. - let prs = ctx - .db - .get_merge_queue_pull_requests(repo_name, priority) - .await?; + let prs = sort_queue_prs(prs); for pr in prs { let pr_num = pr.number; diff --git a/src/database/client.rs b/src/database/client.rs index f9e623ae..90b6d2c7 100644 --- a/src/database/client.rs +++ b/src/database/client.rs @@ -1,7 +1,6 @@ use sqlx::PgPool; use crate::bors::{PullRequestStatus, RollupMode}; -use crate::database::operations::get_merge_queue_pull_requests; use crate::database::{ BuildModel, BuildStatus, PullRequestModel, RepoModel, TreeState, WorkflowModel, WorkflowStatus, WorkflowType, @@ -11,14 +10,14 @@ 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, get_nonclosed_pull_requests, - get_nonclosed_pull_requests_by_base_branch, get_prs_with_unknown_mergeable_state, - get_pull_request, get_repository, get_repository_by_name, get_running_builds, - get_workflow_urls_for_build, get_workflows_for_build, insert_repo_if_not_exists, - set_pr_assignees, set_pr_priority, set_pr_rollup, set_pr_status, unapprove_pull_request, - undelegate_pull_request, update_build_status, update_mergeable_states_by_base_branch, - update_pr_auto_build_id, update_pr_build_id, update_pr_mergeable_state, update_workflow_status, - upsert_pull_request, upsert_repository, + delegate_pull_request, find_build, find_pr_by_build, get_merge_queue_prs, + get_nonclosed_pull_requests, get_nonclosed_pull_requests_by_base_branch, + get_prs_with_unknown_mergeable_state, get_pull_request, get_repository, get_repository_by_name, + get_running_builds, get_workflow_urls_for_build, get_workflows_for_build, + insert_repo_if_not_exists, set_pr_assignees, set_pr_priority, set_pr_rollup, set_pr_status, + unapprove_pull_request, undelegate_pull_request, update_build_status, + update_mergeable_states_by_base_branch, update_pr_auto_build_id, update_pr_build_id, + update_pr_mergeable_state, update_workflow_status, upsert_pull_request, upsert_repository, }; use super::{ApprovalInfo, DelegatedPermission, MergeableState, RunId, UpsertPullRequestParams}; @@ -313,11 +312,11 @@ impl PgDbClient { upsert_repository(&self.pool, repo, tree_state).await } - pub async fn get_merge_queue_pull_requests( + pub async fn get_merge_queue_prs( &self, repo: &GithubRepoName, tree_priority: Option, ) -> anyhow::Result> { - get_merge_queue_pull_requests(&self.pool, repo, tree_priority.map(|p| p as i32)).await + get_merge_queue_prs(&self.pool, repo, tree_priority.map(|p| p as i32)).await } } diff --git a/src/database/operations.rs b/src/database/operations.rs index c7e5f876..25d0a007 100644 --- a/src/database/operations.rs +++ b/src/database/operations.rs @@ -960,12 +960,12 @@ pub(crate) async fn upsert_repository( .await } -pub(crate) async fn get_merge_queue_pull_requests( +pub(crate) async fn get_merge_queue_prs( executor: impl PgExecutor<'_>, repo: &GithubRepoName, tree_priority: Option, ) -> anyhow::Result> { - measure_db_query("get_merge_queue_pull_requests", || async { + measure_db_query("get_approved_mergeable_pull_requests", || async { let records = sqlx::query_as!( PullRequestModel, r#" @@ -998,32 +998,6 @@ pub(crate) async fn get_merge_queue_pull_requests( AND pr.mergeable_state = 'mergeable' -- Tree closure check (if tree_priority is set) AND ($2::int IS NULL OR pr.priority >= $2) - ORDER BY - -- 1. Build status priority - CASE - WHEN auto_build.status = 'pending' THEN 1 - WHEN pr.approved_by IS NOT NULL AND auto_build.id IS NULL THEN 2 - WHEN auto_build.id IS NULL THEN 3 - WHEN auto_build.status IN ('cancelled', 'timeouted') THEN 4 - WHEN auto_build.status = 'failure' THEN 5 - WHEN auto_build.status = 'success' THEN 6 - ELSE -1 - END, - -- 2. Priority - -COALESCE(pr.priority, 0), - -- 3. Rollup (capped at -1) - GREATEST( - CASE - WHEN pr.rollup = 'always' THEN -2 - WHEN pr.rollup = 'maybe' THEN 0 - WHEN pr.rollup = 'iffy' THEN -1 - WHEN pr.rollup = 'never' THEN 1 - ELSE 0 - END, - -1 - ), - -- 4. PR number (older PRs first) - pr.number "#, repo as &GithubRepoName, tree_priority diff --git a/src/github/server.rs b/src/github/server.rs index 5154294e..bd13e663 100644 --- a/src/github/server.rs +++ b/src/github/server.rs @@ -13,6 +13,7 @@ use crate::github::webhook::WebhookSecret; use crate::templates::{ HelpTemplate, HtmlTemplate, NotFoundTemplate, PullRequestStats, QueueTemplate, RepositoryView, }; +use crate::utils::sort_queue::sort_queue_prs; use crate::{BorsGlobalEvent, BorsRepositoryEvent, PgDbClient, TeamApiClient}; use super::AppError; @@ -133,6 +134,7 @@ async fn queue_handler( }; let prs = state.db.get_nonclosed_pull_requests(&repo.name).await?; + let prs = sort_queue_prs(prs); // TODO: add failed count let (approved_count, rolled_up_count) = prs.iter().fold((0, 0), |(approved, rolled_up), pr| { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a33e94a2..9331c240 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ pub mod logging; +pub mod sort_queue; pub mod text; pub mod timing; diff --git a/src/utils/sort_queue.rs b/src/utils/sort_queue.rs new file mode 100644 index 00000000..edfa1761 --- /dev/null +++ b/src/utils/sort_queue.rs @@ -0,0 +1,61 @@ +use crate::bors::RollupMode; +use crate::database::{BuildStatus, MergeableState, PullRequestModel}; + +/// Sorts pull requests according to merge queue priority rules. +/// +/// Ordered by build status > mergeability > approval > +/// priority value > rollup > age. +pub fn sort_queue_prs(mut prs: Vec) -> Vec { + prs.sort_by(|a, b| { + // 1. Compare build status (lower value = higher priority) + get_status_priority(a) + .cmp(&get_status_priority(b)) + // 2. Compare mergeable state (0 = mergeable, 1 = conflicts/unknown) + .then_with(|| get_mergeable_priority(a).cmp(&get_mergeable_priority(b))) + // 3. Compare approval status (approved PRs should come first) + .then_with(|| a.is_approved().cmp(&b.is_approved()).reverse()) + // 4. Compare priority numbers (higher priority should come first) + .then_with(|| b.priority.unwrap_or(0).cmp(&a.priority.unwrap_or(0))) + // 5. Compare rollup mode (-1 = never/iffy, 0 = maybe, 1 = always) + .then_with(|| { + get_rollup_priority(a.rollup.as_ref()).cmp(&get_rollup_priority(b.rollup.as_ref())) + }) + // 6. Compare PR numbers (older first) + .then_with(|| a.number.cmp(&b.number)) + }); + prs +} + +fn get_status_priority(pr: &PullRequestModel) -> i32 { + match &pr.auto_build { + Some(build) => match build.status { + BuildStatus::Pending => 1, + BuildStatus::Success => 6, + BuildStatus::Failure => 5, + BuildStatus::Cancelled | BuildStatus::Timeouted => 4, + }, + None => { + if pr.is_approved() { + 2 // approved but no build + } else { + 3 // no status + } + } + } +} + +fn get_mergeable_priority(pr: &PullRequestModel) -> i32 { + match pr.mergeable_state { + MergeableState::Mergeable => 0, + MergeableState::HasConflicts => 1, + MergeableState::Unknown => 1, + } +} + +fn get_rollup_priority(rollup: Option<&RollupMode>) -> i32 { + match rollup { + Some(RollupMode::Always) => 1, + Some(RollupMode::Maybe) | None => 0, + Some(RollupMode::Iffy) | Some(RollupMode::Never) => -1, + } +} From d5e40509bf469d111df8c90b06a51f37f93a3dfc Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 12:04:17 +0100 Subject: [PATCH 09/19] Update incorrect `.attach_try_build(...)` -> `.attach_auto_build(...)` --- src/bors/merge_queue.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bors/merge_queue.rs b/src/bors/merge_queue.rs index d9aeaa25..af5e87e0 100644 --- a/src/bors/merge_queue.rs +++ b/src/bors/merge_queue.rs @@ -144,7 +144,7 @@ async fn start_auto_build( // 3. Record the build in the database ctx.db - .attach_try_build( + .attach_auto_build( &pr, AUTO_BRANCH_NAME.to_string(), merge_sha.clone(), From 4b5d5bf8428ca2c261ee8dfc3c368e9d41ec326b Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 12:08:56 +0100 Subject: [PATCH 10/19] Set PR state to has conflicts on auto build merge --- src/bors/merge_queue.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bors/merge_queue.rs b/src/bors/merge_queue.rs index af5e87e0..aed30494 100644 --- a/src/bors/merge_queue.rs +++ b/src/bors/merge_queue.rs @@ -10,7 +10,7 @@ use crate::{ comment::{auto_build_started_comment, merge_conflict_comment}, handlers::labels::handle_label_trigger, }, - database::{BuildStatus, PullRequestModel}, + database::{BuildStatus, MergeableState, PullRequestModel}, github::{CommitSha, LabelTrigger, MergeError, api::client::GithubRepositoryClient}, utils::sort_queue::sort_queue_prs, }; @@ -180,6 +180,10 @@ async fn start_auto_build( repo.client .post_comment(pr.number, merge_conflict_comment(&gh_pr.head.name)) .await?; + ctx.db + .update_pr_mergeable_state(&pr, MergeableState::HasConflicts) + .await?; + Ok(false) } } From a842646b01c27b2b1df59bda36e52eb05585086b Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 13:32:08 +0100 Subject: [PATCH 11/19] Add new process merge queue refresh event, triggered every 30s --- src/bin/bors.rs | 7 +++++++ src/bors/event.rs | 2 ++ src/bors/handlers/mod.rs | 4 ++++ src/github/server.rs | 30 ++++++++++++++++++++++-------- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/bin/bors.rs b/src/bin/bors.rs index 75536df2..bccc9aed 100644 --- a/src/bin/bors.rs +++ b/src/bin/bors.rs @@ -32,6 +32,9 @@ 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); +/// How often should the bot process the merge queue. +const MERGE_QUEUE_INTERVAL: Duration = Duration::from_secs(30); + #[derive(clap::Parser)] struct Opts { /// Github App ID. @@ -157,6 +160,7 @@ fn try_main(opts: Opts) -> anyhow::Result<()> { 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); + let mut merge_queue_interval = make_interval(MERGE_QUEUE_INTERVAL); loop { tokio::select! { _ = config_refresh.tick() => { @@ -174,6 +178,9 @@ fn try_main(opts: Opts) -> anyhow::Result<()> { _ = prs_interval.tick() => { refresh_tx.send(BorsGlobalEvent::RefreshPullRequestState).await?; } + _ = merge_queue_interval.tick() => { + refresh_tx.send(BorsGlobalEvent::ProcessMergeQueue).await?; + } } } }; diff --git a/src/bors/event.rs b/src/bors/event.rs index 24f764ab..26542039 100644 --- a/src/bors/event.rs +++ b/src/bors/event.rs @@ -75,6 +75,8 @@ pub enum BorsGlobalEvent { RefreshPullRequestMergeability, /// Periodic event that serves for synchronizing PR state. RefreshPullRequestState, + /// Process the merge queue. + ProcessMergeQueue, } #[derive(Debug)] diff --git a/src/bors/handlers/mod.rs b/src/bors/handlers/mod.rs index 58868241..3a60e629 100644 --- a/src/bors/handlers/mod.rs +++ b/src/bors/handlers/mod.rs @@ -241,6 +241,7 @@ pub async fn handle_bors_global_event( gh_client: &Octocrab, team_api_client: &TeamApiClient, mergeable_queue_tx: MergeableQueueSender, + merge_queue_tx: mpsc::Sender, ) -> anyhow::Result<()> { let db = Arc::clone(&ctx.db); match event { @@ -316,6 +317,9 @@ pub async fn handle_bors_global_event( #[cfg(test)] crate::bors::WAIT_FOR_PR_STATUS_REFRESH.mark(); } + BorsGlobalEvent::ProcessMergeQueue => { + merge_queue_tx.send(()).await?; + } } Ok(()) } diff --git a/src/github/server.rs b/src/github/server.rs index bd13e663..40d824ed 100644 --- a/src/github/server.rs +++ b/src/github/server.rs @@ -220,10 +220,17 @@ pub fn create_bors_process( consume_repository_events( ctx.clone(), repository_rx, - merge_queue_tx, + merge_queue_tx.clone(), mq_tx.clone() ), - consume_global_events(ctx.clone(), global_rx, mq_tx, gh_client, team_api), + consume_global_events( + ctx.clone(), + global_rx, + mq_tx.clone(), + merge_queue_tx, + gh_client, + team_api + ), consume_merge_queue(ctx.clone(), merge_queue_rx), consume_mergeable_queue(ctx, mergeable_queue_rx) ); @@ -234,10 +241,10 @@ pub fn create_bors_process( #[cfg(not(test))] { tokio::select! { - _ = consume_repository_events(ctx.clone(), repository_rx, merge_queue_tx, mq_tx.clone()) => { + _ = consume_repository_events(ctx.clone(), repository_rx, merge_queue_tx.clone(), mq_tx.clone()) => { tracing::error!("Repository event handling process has ended"); } - _ = consume_global_events(ctx.clone(), global_rx, mq_tx, gh_client, team_api) => { + _ = consume_global_events(ctx.clone(), global_rx, mq_tx, merge_queue_tx, gh_client, team_api) => { tracing::error!("Global event handling process has ended"); } _ = consume_merge_queue(ctx.clone(), merge_queue_rx) => { @@ -286,6 +293,7 @@ async fn consume_global_events( ctx: Arc, mut global_rx: mpsc::Receiver, mergeable_queue_tx: MergeableQueueSender, + merge_queue_tx: mpsc::Sender, gh_client: Octocrab, team_api: TeamApiClient, ) { @@ -295,10 +303,16 @@ async fn consume_global_events( let span = tracing::info_span!("GlobalEvent"); tracing::debug!("Received global event: {event:#?}"); - if let Err(error) = - handle_bors_global_event(event, ctx, &gh_client, &team_api, mergeable_queue_tx) - .instrument(span.clone()) - .await + if let Err(error) = handle_bors_global_event( + event, + ctx, + &gh_client, + &team_api, + mergeable_queue_tx, + merge_queue_tx.clone(), + ) + .instrument(span.clone()) + .await { handle_root_error(span, error); } From 8b8805bc01b44b327138ea018278a273af121261 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 13:34:08 +0100 Subject: [PATCH 12/19] Log number of drained merge queue invocations --- src/github/server.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/github/server.rs b/src/github/server.rs index 40d824ed..2134683f 100644 --- a/src/github/server.rs +++ b/src/github/server.rs @@ -324,16 +324,20 @@ async fn consume_merge_queue( mut merge_queue_rx: mpsc::Receiver, ) { while merge_queue_rx.recv().await.is_some() { - // Drain any extras that have arrived. + let mut drained_count = 0; + // Drain any extras that have arrived. We can do this as we know the state of + // the queue has changed and this single run should capture those changes. while let Ok(()) = merge_queue_rx.try_recv() { - // We can do this as we know the state of the queue has changed - // and this single run should capture those changes. + drained_count += 1; } let ctx = ctx.clone(); let span = tracing::info_span!("MergeQueue"); - tracing::debug!("Processing merge queue"); + tracing::debug!( + "Processing merge queue, drained {} extra events", + drained_count + ); if let Err(error) = handle_merge_queue(ctx).instrument(span.clone()).await { handle_root_error(span, error); } From 4346932819a1058815236b120b30391322f95ee5 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 13:35:36 +0100 Subject: [PATCH 13/19] Remove merge queue invocation after each parsed comment We do not really need this anymore since we invoce on refresh --- src/bors/handlers/mod.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/bors/handlers/mod.rs b/src/bors/handlers/mod.rs index 3a60e629..9162b781 100644 --- a/src/bors/handlers/mod.rs +++ b/src/bors/handlers/mod.rs @@ -84,7 +84,7 @@ pub async fn handle_bors_repository_event( author = comment.author.username ); let pr_number = comment.pr_number; - if let Err(error) = handle_comment(Arc::clone(&repo), db, ctx, merge_queue_tx, comment) + if let Err(error) = handle_comment(Arc::clone(&repo), db, ctx, comment) .instrument(span.clone()) .await { @@ -354,7 +354,6 @@ async fn handle_comment( repo: Arc, database: Arc, ctx: Arc, - merge_queue_tx: mpsc::Sender, comment: PullRequestComment, ) -> anyhow::Result<()> { let pr_number = comment.pr_number; @@ -523,10 +522,6 @@ async fn handle_comment( } } - if let Err(err) = merge_queue_tx.send(()).await { - tracing::error!("Failed to send merge queue message: {err}"); - } - Ok(()) } From 3f92743df6a8914174f021b9c7e4d093857771d9 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 14:53:38 +0100 Subject: [PATCH 14/19] Filter out failed auto builds in `get_merge_queue_prs` DB operation --- ...d485aa838e1b287fba9184d2b767dc9d8dd1c895c3172a4c0.json} | 4 ++-- src/bors/merge_queue.rs | 7 +------ src/database/operations.rs | 4 +++- 3 files changed, 6 insertions(+), 9 deletions(-) rename .sqlx/{query-9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711.json => query-b17fe5f20832f55d485aa838e1b287fba9184d2b767dc9d8dd1c895c3172a4c0.json} (93%) diff --git a/.sqlx/query-9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711.json b/.sqlx/query-b17fe5f20832f55d485aa838e1b287fba9184d2b767dc9d8dd1c895c3172a4c0.json similarity index 93% rename from .sqlx/query-9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711.json rename to .sqlx/query-b17fe5f20832f55d485aa838e1b287fba9184d2b767dc9d8dd1c895c3172a4c0.json index 137e8251..7ff7d25c 100644 --- a/.sqlx/query-9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711.json +++ b/.sqlx/query-b17fe5f20832f55d485aa838e1b287fba9184d2b767dc9d8dd1c895c3172a4c0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n WHERE pr.repository = $1\n AND pr.status = 'open'\n AND pr.approved_by IS NOT NULL\n AND pr.mergeable_state = 'mergeable'\n -- Tree closure check (if tree_priority is set)\n AND ($2::int IS NULL OR pr.priority >= $2)\n ", + "query": "\n SELECT\n pr.id,\n pr.repository as \"repository: GithubRepoName\",\n pr.number as \"number!: i64\",\n pr.title,\n pr.author,\n pr.assignees as \"assignees: Assignees\",\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 try_build AS \"try_build: BuildModel\",\n auto_build AS \"auto_build: BuildModel\"\n FROM pull_request as pr\n LEFT JOIN build AS try_build ON pr.build_id = try_build.id\n LEFT JOIN build AS auto_build ON pr.auto_build_id = auto_build.id\n WHERE pr.repository = $1\n AND pr.status = 'open'\n AND pr.approved_by IS NOT NULL\n AND pr.mergeable_state = 'mergeable'\n -- Exclude PRs with failed auto builds\n AND (auto_build.status IS NULL OR auto_build.status NOT IN ('failure', 'cancelled', 'timeouted'))\n -- Tree closure check (if tree_priority is set)\n AND ($2::int IS NULL OR pr.priority >= $2)\n ", "describe": { "columns": [ { @@ -181,5 +181,5 @@ null ] }, - "hash": "9cfb3f93bcc7b92c994d90ab2b54c3f90fc7d1fa3d2793d01c1e664b1e77a711" + "hash": "b17fe5f20832f55d485aa838e1b287fba9184d2b767dc9d8dd1c895c3172a4c0" } diff --git a/src/bors/merge_queue.rs b/src/bors/merge_queue.rs index aed30494..e2e7ad2b 100644 --- a/src/bors/merge_queue.rs +++ b/src/bors/merge_queue.rs @@ -73,12 +73,7 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { todo!("Implement stuck PR recovery mechanism"); } BuildStatus::Failure | BuildStatus::Cancelled | BuildStatus::Timeouted => { - tracing::debug!( - "PR {} has a failed build ({}), skipping", - pr.number, - auto_build.status - ); - continue; + unreachable!("Failed auto builds should be filtered out by SQL query"); } } } diff --git a/src/database/operations.rs b/src/database/operations.rs index 25d0a007..2ea67da2 100644 --- a/src/database/operations.rs +++ b/src/database/operations.rs @@ -965,7 +965,7 @@ pub(crate) async fn get_merge_queue_prs( repo: &GithubRepoName, tree_priority: Option, ) -> anyhow::Result> { - measure_db_query("get_approved_mergeable_pull_requests", || async { + measure_db_query("get_merge_queue_prs", || async { let records = sqlx::query_as!( PullRequestModel, r#" @@ -996,6 +996,8 @@ pub(crate) async fn get_merge_queue_prs( AND pr.status = 'open' AND pr.approved_by IS NOT NULL AND pr.mergeable_state = 'mergeable' + -- Exclude PRs with failed auto builds + AND (auto_build.status IS NULL OR auto_build.status NOT IN ('failure', 'cancelled', 'timeouted')) -- Tree closure check (if tree_priority is set) AND ($2::int IS NULL OR pr.priority >= $2) "#, From 18c331cc51024f69287c7deba45ada91531e1073 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 15:46:01 +0100 Subject: [PATCH 15/19] Clear auto build references on PR push --- ...e19a9ae88785203c49bc4622a704d9bef220d.json | 14 +++++++++++ src/bors/handlers/pr_events.rs | 23 +++++++++---------- src/database/client.rs | 6 ++++- src/database/operations.rs | 16 +++++++++++++ 4 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 .sqlx/query-aa9f3483df0d2ab722603300368e19a9ae88785203c49bc4622a704d9bef220d.json diff --git a/.sqlx/query-aa9f3483df0d2ab722603300368e19a9ae88785203c49bc4622a704d9bef220d.json b/.sqlx/query-aa9f3483df0d2ab722603300368e19a9ae88785203c49bc4622a704d9bef220d.json new file mode 100644 index 00000000..03d80317 --- /dev/null +++ b/.sqlx/query-aa9f3483df0d2ab722603300368e19a9ae88785203c49bc4622a704d9bef220d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE pull_request SET auto_build_id = NULL WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "aa9f3483df0d2ab722603300368e19a9ae88785203c49bc4622a704d9bef220d" +} diff --git a/src/bors/handlers/pr_events.rs b/src/bors/handlers/pr_events.rs index 4ffc3a7a..b1d412a1 100644 --- a/src/bors/handlers/pr_events.rs +++ b/src/bors/handlers/pr_events.rs @@ -54,7 +54,7 @@ pub(super) async fn handle_push_to_pull_request( mergeable_queue.enqueue(repo_state.repository().clone(), pr_number); - cancel_active_auto_build_for_pr(&repo_state, &db, &pr_model).await?; + clear_auto_build_for_pr(&repo_state, &db, &pr_model).await?; if !pr_model.is_approved() { return Ok(()); @@ -264,7 +264,7 @@ PR will need to be re-approved."#, .await } -async fn cancel_active_auto_build_for_pr( +async fn clear_auto_build_for_pr( repo_state: &RepositoryState, db: &PgDbClient, pr_model: &PullRequestModel, @@ -273,21 +273,20 @@ async fn cancel_active_auto_build_for_pr( return Ok(()); }; - if auto_build.status != BuildStatus::Pending { - return Ok(()); + if auto_build.status == BuildStatus::Pending { + tracing::info!( + "Cancelling auto build for PR {} due to push", + pr_model.number + ); + + cancel_build_workflows(&repo_state.client, db, auto_build).await?; } tracing::info!( - "Cancelling auto build for PR {} due to push", + "Clearing auto build reference for PR {} due to push", pr_model.number ); - - if let Err(error) = cancel_build_workflows(&repo_state.client, db, auto_build).await { - tracing::error!( - "Could not cancel auto build workflows for PR {}: {error:?}", - pr_model.number - ); - } + db.clear_pr_auto_build(pr_model).await?; Ok(()) } diff --git a/src/database/client.rs b/src/database/client.rs index 90b6d2c7..abaaff77 100644 --- a/src/database/client.rs +++ b/src/database/client.rs @@ -9,7 +9,7 @@ use crate::github::PullRequestNumber; use crate::github::{CommitSha, GithubRepoName}; use super::operations::{ - approve_pull_request, create_build, create_pull_request, create_workflow, + approve_pull_request, clear_pr_auto_build, create_build, create_pull_request, create_workflow, delegate_pull_request, find_build, find_pr_by_build, get_merge_queue_prs, get_nonclosed_pull_requests, get_nonclosed_pull_requests_by_base_branch, get_prs_with_unknown_mergeable_state, get_pull_request, get_repository, get_repository_by_name, @@ -319,4 +319,8 @@ impl PgDbClient { ) -> anyhow::Result> { get_merge_queue_prs(&self.pool, repo, tree_priority.map(|p| p as i32)).await } + + pub async fn clear_pr_auto_build(&self, pr: &PullRequestModel) -> anyhow::Result<()> { + clear_pr_auto_build(&self.pool, pr.id).await + } } diff --git a/src/database/operations.rs b/src/database/operations.rs index 2ea67da2..f1b2641b 100644 --- a/src/database/operations.rs +++ b/src/database/operations.rs @@ -533,6 +533,22 @@ pub(crate) async fn update_pr_auto_build_id( .await } +pub(crate) async fn clear_pr_auto_build( + executor: impl PgExecutor<'_>, + pr_id: i32, +) -> anyhow::Result<()> { + measure_db_query("clear_pr_auto_build", || async { + sqlx::query!( + "UPDATE pull_request SET auto_build_id = NULL WHERE id = $1", + pr_id + ) + .execute(executor) + .await?; + Ok(()) + }) + .await +} + pub(crate) async fn create_build( executor: impl PgExecutor<'_>, repo: &GithubRepoName, From e0255de04b0693ca89c108a9a22844542e693cb5 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 17:34:27 +0100 Subject: [PATCH 16/19] Move base branch operation to merge queue --- src/bors/comment.rs | 9 +++++ src/bors/handlers/workflow.rs | 62 +++++++---------------------------- src/bors/merge_queue.rs | 42 +++++++++++++++++++----- 3 files changed, 53 insertions(+), 60 deletions(-) diff --git a/src/bors/comment.rs b/src/bors/comment.rs index 616a64aa..e1ca2d2f 100644 --- a/src/bors/comment.rs +++ b/src/bors/comment.rs @@ -221,3 +221,12 @@ pub fn auto_build_failed_comment(workflows: &[WorkflowModel]) -> Comment { Comment::new(":broken_heart: Test failed".to_string()) } } + +pub fn auto_build_push_failed_comment(merge_sha: &CommitSha, base_branch: &str) -> Comment { + Comment::new(format!( + ":x: Failed to push commit `{}` to `{}` branch.\n\n\ +This may be a temporary GitHub issue and will be retried on the next queue run.\n\n\ +", + merge_sha, base_branch + )) +} diff --git a/src/bors/handlers/workflow.rs b/src/bors/handlers/workflow.rs index 614b9148..dd87aea3 100644 --- a/src/bors/handlers/workflow.rs +++ b/src/bors/handlers/workflow.rs @@ -6,7 +6,6 @@ use tokio::sync::mpsc; use crate::PgDbClient; use crate::bors::CheckSuiteStatus; -use crate::bors::PullRequestStatus; use crate::bors::RepositoryState; use crate::bors::comment::auto_build_failed_comment; use crate::bors::comment::auto_build_succeeded_comment; @@ -217,19 +216,9 @@ async fn try_complete_build( complete_try_build(repo, pr, build, workflows, has_failure, payload).await? } AUTO_BRANCH_NAME => { - complete_auto_build( - db, - repo, - pr, - build, - workflows, - has_failure, - merge_queue_tx, - payload, - ) - .await? + complete_auto_build(repo, pr, workflows, has_failure, payload, merge_queue_tx).await?; } - _ => {} + _ => unreachable!("Branch should only be bors-observed branch"), } Ok(()) @@ -257,59 +246,32 @@ async fn complete_try_build( } /// Complete the auto build workflow. -#[allow(clippy::too_many_arguments)] async fn complete_auto_build( - db: &PgDbClient, repo: &RepositoryState, pr: PullRequestModel, - build: BuildModel, workflows: Vec, has_failure: bool, - merge_queue_tx: mpsc::Sender, payload: CheckSuiteCompleted, + merge_queue_tx: mpsc::Sender, ) -> anyhow::Result<()> { - let (build_succeeded, merge_succeeded) = if has_failure { + let comment = if !has_failure { tracing::info!("Auto build failed"); - (false, false) - } else { - // Update base branch to point to the tested commit - match repo - .client - .set_branch_to_sha(&pr.base_branch, &payload.commit_sha) - .await - { - Ok(()) => { - tracing::info!("Auto build succeeded and merged"); - db.set_pr_status(&pr.repository, pr.number, PullRequestStatus::Merged) - .await?; - (true, true) - } - Err(e) => { - tracing::error!("Failed to push to base branch: {:?}", e); - db.update_build_status(&build, BuildStatus::Failure).await?; - (true, false) - } - } - }; - - let message = if !build_succeeded { auto_build_failed_comment(&workflows) - } else if merge_succeeded { + } else { + tracing::info!("Auto build succeeded"); auto_build_succeeded_comment( &workflows, pr.approver().unwrap_or(""), &payload.commit_sha, &pr.base_branch, ) - } else { - todo!("Deal with failed branch push"); }; - repo.client.post_comment(pr.number, message).await?; + repo.client.post_comment(pr.number, comment).await?; - let (gh_status, gh_desc) = if build_succeeded && merge_succeeded { - (StatusState::Success, "Build succeeded") - } else { + let (gh_status, gh_desc) = if !has_failure { (StatusState::Failure, "Build failed") + } else { + (StatusState::Success, "Build succeeded") }; let gh_pr = repo.client.get_pull_request(pr.number).await?; @@ -323,9 +285,7 @@ async fn complete_auto_build( ) .await?; - if let Err(err) = merge_queue_tx.send(()).await { - tracing::error!("Failed to invoke merge queue: {err}"); - } + merge_queue_tx.send(()).await?; Ok(()) } diff --git a/src/bors/merge_queue.rs b/src/bors/merge_queue.rs index e2e7ad2b..3a098154 100644 --- a/src/bors/merge_queue.rs +++ b/src/bors/merge_queue.rs @@ -6,8 +6,10 @@ use std::sync::Arc; use crate::{ BorsContext, bors::{ - RepositoryState, - comment::{auto_build_started_comment, merge_conflict_comment}, + PullRequestStatus, RepositoryState, + comment::{ + auto_build_push_failed_comment, auto_build_started_comment, merge_conflict_comment, + }, handlers::labels::handle_label_trigger, }, database::{BuildStatus, MergeableState, PullRequestModel}, @@ -55,6 +57,8 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { let pr_num = pr.number; if let Some(auto_build) = &pr.auto_build { + let commit_sha = CommitSha(auto_build.commit_sha.clone()); + match auto_build.status { // Build in progress - stop queue. We can only have one PR built at a time. BuildStatus::Pending => { @@ -63,14 +67,34 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { ); break; } - // Successful builds should already be merged via webhooks, - // so we handle stuck PRs caused by GitHub failures or crashes here. + // Build successful- point the base branch to the merged commit. BuildStatus::Success => { - tracing::warn!( - "PR {} has a successful build, skipping (should already be merged)", - pr.number - ); - todo!("Implement stuck PR recovery mechanism"); + match repo + .client + .set_branch_to_sha(&pr.base_branch, &commit_sha) + .await + { + Ok(()) => { + tracing::info!("Auto build succeeded and merged"); + ctx.db + .set_pr_status( + &pr.repository, + pr.number, + PullRequestStatus::Merged, + ) + .await?; + } + Err(e) => { + tracing::error!("Failed to push to base branch: {:?}", e); + + let comment = + auto_build_push_failed_comment(&commit_sha, &pr.base_branch); + repo.client.post_comment(pr.number, comment).await?; + + // Continue to the next PR and keep it in the queue. + continue; + } + }; } BuildStatus::Failure | BuildStatus::Cancelled | BuildStatus::Timeouted => { unreachable!("Failed auto builds should be filtered out by SQL query"); From bfe18d319063d8c3366aa1bf573949ad64a7f9d8 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 17:53:44 +0100 Subject: [PATCH 17/19] Invoke merge after approval and unapproval --- src/bors/handlers/mod.rs | 8 ++++++-- src/bors/handlers/review.rs | 13 +++++++++++-- src/bors/merge_queue.rs | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/bors/handlers/mod.rs b/src/bors/handlers/mod.rs index 9162b781..b8ba6e25 100644 --- a/src/bors/handlers/mod.rs +++ b/src/bors/handlers/mod.rs @@ -84,7 +84,7 @@ pub async fn handle_bors_repository_event( author = comment.author.username ); let pr_number = comment.pr_number; - if let Err(error) = handle_comment(Arc::clone(&repo), db, ctx, comment) + if let Err(error) = handle_comment(Arc::clone(&repo), db, ctx, merge_queue_tx, comment) .instrument(span.clone()) .await { @@ -354,6 +354,7 @@ async fn handle_comment( repo: Arc, database: Arc, ctx: Arc, + merge_queue_tx: mpsc::Sender, comment: PullRequestComment, ) -> anyhow::Result<()> { let pr_number = comment.pr_number; @@ -374,6 +375,8 @@ async fn handle_comment( .with_context(|| format!("Cannot get information about PR {pr_number}"))?; for command in commands { + let merge_queue_tx = merge_queue_tx.clone(); + match command { Ok(command) => { // Reload the PR state from DB, because a previous command might have changed it. @@ -399,6 +402,7 @@ async fn handle_comment( command_approve( repo, database, + merge_queue_tx, &pr, &comment.author, &approver, @@ -429,7 +433,7 @@ async fn handle_comment( } BorsCommand::Unapprove => { let span = tracing::info_span!("Unapprove"); - command_unapprove(repo, database, &pr, &comment.author) + command_unapprove(repo, database, merge_queue_tx, &pr, &comment.author) .instrument(span) .await } diff --git a/src/bors/handlers/review.rs b/src/bors/handlers/review.rs index 1e7577d3..93b7fb79 100644 --- a/src/bors/handlers/review.rs +++ b/src/bors/handlers/review.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use tokio::sync::mpsc; + use crate::PgDbClient; use crate::bors::Comment; use crate::bors::RepositoryState; @@ -8,6 +10,7 @@ use crate::bors::command::RollupMode; use crate::bors::handlers::has_permission; use crate::bors::handlers::labels::handle_label_trigger; use crate::bors::handlers::{PullRequestData, deny_request}; +use crate::bors::merge_queue::MergeQueueEvent; use crate::database::ApprovalInfo; use crate::database::DelegatedPermission; use crate::database::TreeState; @@ -20,6 +23,7 @@ use crate::permissions::PermissionType; pub(super) async fn command_approve( repo_state: Arc, db: Arc, + merge_queue_tx: mpsc::Sender, pr: &PullRequestData, author: &GithubUser, approver: &Approver, @@ -42,7 +46,9 @@ pub(super) async fn command_approve( db.approve(&pr.db, approval_info, priority, rollup).await?; handle_label_trigger(&repo_state, pr.number(), LabelTrigger::Approved).await?; - notify_of_approval(&repo_state, pr, approver.as_str()).await + notify_of_approval(&repo_state, pr, approver.as_str()).await?; + merge_queue_tx.send(()).await?; + Ok(()) } /// Unapprove a pull request. @@ -50,6 +56,7 @@ pub(super) async fn command_approve( pub(super) async fn command_unapprove( repo_state: Arc, db: Arc, + merge_queue_tx: mpsc::Sender, pr: &PullRequestData, author: &GithubUser, ) -> anyhow::Result<()> { @@ -61,7 +68,9 @@ pub(super) async fn command_unapprove( db.unapprove(&pr.db).await?; handle_label_trigger(&repo_state, pr.number(), LabelTrigger::Unapproved).await?; - notify_of_unapproval(&repo_state, pr).await + notify_of_unapproval(&repo_state, pr).await?; + merge_queue_tx.send(()).await?; + Ok(()) } /// Set the priority of a pull request. diff --git a/src/bors/merge_queue.rs b/src/bors/merge_queue.rs index 3a098154..9dbbabc6 100644 --- a/src/bors/merge_queue.rs +++ b/src/bors/merge_queue.rs @@ -67,7 +67,7 @@ pub async fn handle_merge_queue(ctx: Arc) -> anyhow::Result<()> { ); break; } - // Build successful- point the base branch to the merged commit. + // Build successful - point the base branch to the merged commit. BuildStatus::Success => { match repo .client From 19c39fdf4407652183cc07dc5173a7327d930c12 Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 17:57:44 +0100 Subject: [PATCH 18/19] Remove merge invocation after unapproval It does not make sense to invoke after unapproval --- src/bors/handlers/mod.rs | 2 +- src/bors/handlers/review.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/bors/handlers/mod.rs b/src/bors/handlers/mod.rs index b8ba6e25..c246effb 100644 --- a/src/bors/handlers/mod.rs +++ b/src/bors/handlers/mod.rs @@ -433,7 +433,7 @@ async fn handle_comment( } BorsCommand::Unapprove => { let span = tracing::info_span!("Unapprove"); - command_unapprove(repo, database, merge_queue_tx, &pr, &comment.author) + command_unapprove(repo, database, &pr, &comment.author) .instrument(span) .await } diff --git a/src/bors/handlers/review.rs b/src/bors/handlers/review.rs index 93b7fb79..2060e2f2 100644 --- a/src/bors/handlers/review.rs +++ b/src/bors/handlers/review.rs @@ -56,7 +56,6 @@ pub(super) async fn command_approve( pub(super) async fn command_unapprove( repo_state: Arc, db: Arc, - merge_queue_tx: mpsc::Sender, pr: &PullRequestData, author: &GithubUser, ) -> anyhow::Result<()> { @@ -68,9 +67,7 @@ pub(super) async fn command_unapprove( db.unapprove(&pr.db).await?; handle_label_trigger(&repo_state, pr.number(), LabelTrigger::Unapproved).await?; - notify_of_unapproval(&repo_state, pr).await?; - merge_queue_tx.send(()).await?; - Ok(()) + notify_of_unapproval(&repo_state, pr).await } /// Set the priority of a pull request. From 0777208b5916c8eb1c7d844acfbd4c367385c85f Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 20 Jun 2025 17:58:49 +0100 Subject: [PATCH 19/19] Allow clippy too many args on `command_approve` --- src/bors/handlers/review.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bors/handlers/review.rs b/src/bors/handlers/review.rs index 2060e2f2..e061a576 100644 --- a/src/bors/handlers/review.rs +++ b/src/bors/handlers/review.rs @@ -20,6 +20,7 @@ use crate::permissions::PermissionType; /// Approve a pull request. /// A pull request can only be approved by a user of sufficient authority. +#[allow(clippy::too_many_arguments)] pub(super) async fn command_approve( repo_state: Arc, db: Arc,