From 55dea05de10dabc089276e496f739110b67db644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pereira?= Date: Mon, 26 Jan 2026 14:30:01 +0100 Subject: [PATCH 1/3] Wait for rapid approvals that may be required for merge --- src/main/java/service/GitLabService.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/service/GitLabService.java b/src/main/java/service/GitLabService.java index 5666318..5fa71b5 100644 --- a/src/main/java/service/GitLabService.java +++ b/src/main/java/service/GitLabService.java @@ -404,7 +404,12 @@ private MergeRequestResult acceptAutoMergeRequest(String gitlabEventUUID, MergeR final Long mrNumber = mr.getIid(); final Long project = mr.getProjectId(); - if (!isMergeRequestApproved(gitlabEventUUID, mr)) { + int countDownWaitingApprovals = MAX_RETRY_ATTEMPTS; + while (!isMergeRequestApproved(gitlabEventUUID, mr) && countDownWaitingApprovals-- > 0) { + Log.infof("GitlabEvent: '%s' | Waiting for approvals in MR '!%d' in project '%d'. Retrying... %d", gitlabEventUUID, mrNumber, project, Math.abs(countDownWaitingApprovals - MAX_RETRY_ATTEMPTS)); + sleepSeconds(1); + } + if (countDownWaitingApprovals < 0) { mr = assignMrToCascadeResponsible(gitlabEventUUID, mr); Log.warnf("GitlabEvent: '%s' | Merge request '!%d' in project '%d' cannot be accepted because it does not have the required amount of approvals", gitlabEventUUID, mrNumber, project); MergeRequestResult result = new MergeRequestResult(mr); @@ -427,8 +432,8 @@ private MergeRequestResult acceptAutoMergeRequest(String gitlabEventUUID, MergeR .withMergeCommitMessage(String.format("%s Automatic merge: '%s' -> '%s'", UCASCADE_TAG, sourceBranchPretty, targetBranch)) .withShouldRemoveSourceBranch(mr.getForceRemoveSourceBranch()); - int countDown = MAX_RETRY_ATTEMPTS; - while (state != MergeRequestUcascadeState.MERGED && countDown-- > 0) { + int countDownWaitingMerged = MAX_RETRY_ATTEMPTS; + while (state != MergeRequestUcascadeState.MERGED && countDownWaitingMerged-- > 0) { boolean hasPipeline = hasPipeline(gitlabEventUUID, mr); acceptMrParams.withMergeWhenPipelineSucceeds(hasPipeline); Log.infof("GitlabEvent: '%s' | Merging MR '!%d': '%s' -> '%s' when pipeline succeeds: '%b'", gitlabEventUUID, mrNumber, sourceBranch, targetBranch, hasPipeline); @@ -436,12 +441,12 @@ private MergeRequestResult acceptAutoMergeRequest(String gitlabEventUUID, MergeR mr = mrApi.acceptMergeRequest(project, mrNumber, acceptMrParams); state = MergeRequestUcascadeState.MERGED; } catch (GitLabApiException e) { - if (countDown == 0) { + if (countDownWaitingMerged == 0) { Log.warnf(e, "GitlabEvent: '%s' | Cannot accept merge request '!%d' from '%s' into '%s' in project '%d'", gitlabEventUUID, mrNumber, sourceBranch, targetBranch, project); state = MergeRequestUcascadeState.NOT_MERGED_UNKNOWN_REASON; mr = assignMrToCascadeResponsible(gitlabEventUUID, mr); } else { - Log.infof("GitlabEvent: '%s' | Merge failed for MR '!%d', status '%s'. Retrying... %d", gitlabEventUUID, mrNumber, mergeStatus, Math.abs(countDown - MAX_RETRY_ATTEMPTS)); + Log.infof("GitlabEvent: '%s' | Merge failed for MR '!%d', status '%s'. Retrying... %d", gitlabEventUUID, mrNumber, mergeStatus, Math.abs(countDownWaitingMerged - MAX_RETRY_ATTEMPTS)); sleepSeconds(1); } } From a9fdf244ccb7c82146adfa78e01d0e0eb602e569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pereira?= Date: Mon, 26 Jan 2026 14:53:37 +0100 Subject: [PATCH 2/3] make max retry attempts configurable default continues to be 60 the value of 1 is explicitly set for tests --- src/main/java/service/GitLabService.java | 14 ++++++++------ src/test/java/com/unblu/ucascade/UcascadeTest.java | 2 +- src/test/resources/application.properties | 3 ++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/java/service/GitLabService.java b/src/main/java/service/GitLabService.java index 5fa71b5..afd1ba1 100644 --- a/src/main/java/service/GitLabService.java +++ b/src/main/java/service/GitLabService.java @@ -60,6 +60,9 @@ public class GitLabService { @ConfigProperty(name = "gitlab.api.token.approver") Optional apiTokenApprover; + @ConfigProperty(name = "ucascade.max-retry-attempts", defaultValue = "60") + int maxRetryAttempts; + @Inject GitInfo gitInfo; @@ -70,7 +73,6 @@ public class GitLabService { private static final String UCASCADE_TAG = "[ucascade]"; private static final String UCASCADE_BRANCH_PATTERN_PREFIX = "^mr(\\d+)_"; private static final String UCASCADE_BRANCH_PATTERN = UCASCADE_BRANCH_PATTERN_PREFIX + ".+"; - private static final int MAX_RETRY_ATTEMPTS = 60; private GitLabApi gitlab; private GitLabApi gitlabApprover; @@ -404,9 +406,9 @@ private MergeRequestResult acceptAutoMergeRequest(String gitlabEventUUID, MergeR final Long mrNumber = mr.getIid(); final Long project = mr.getProjectId(); - int countDownWaitingApprovals = MAX_RETRY_ATTEMPTS; + int countDownWaitingApprovals = maxRetryAttempts; while (!isMergeRequestApproved(gitlabEventUUID, mr) && countDownWaitingApprovals-- > 0) { - Log.infof("GitlabEvent: '%s' | Waiting for approvals in MR '!%d' in project '%d'. Retrying... %d", gitlabEventUUID, mrNumber, project, Math.abs(countDownWaitingApprovals - MAX_RETRY_ATTEMPTS)); + Log.infof("GitlabEvent: '%s' | Waiting for approvals in MR '!%d' in project '%d'. Retrying... %d", gitlabEventUUID, mrNumber, project, Math.abs(countDownWaitingApprovals - maxRetryAttempts)); sleepSeconds(1); } if (countDownWaitingApprovals < 0) { @@ -432,7 +434,7 @@ private MergeRequestResult acceptAutoMergeRequest(String gitlabEventUUID, MergeR .withMergeCommitMessage(String.format("%s Automatic merge: '%s' -> '%s'", UCASCADE_TAG, sourceBranchPretty, targetBranch)) .withShouldRemoveSourceBranch(mr.getForceRemoveSourceBranch()); - int countDownWaitingMerged = MAX_RETRY_ATTEMPTS; + int countDownWaitingMerged = maxRetryAttempts; while (state != MergeRequestUcascadeState.MERGED && countDownWaitingMerged-- > 0) { boolean hasPipeline = hasPipeline(gitlabEventUUID, mr); acceptMrParams.withMergeWhenPipelineSucceeds(hasPipeline); @@ -446,7 +448,7 @@ private MergeRequestResult acceptAutoMergeRequest(String gitlabEventUUID, MergeR state = MergeRequestUcascadeState.NOT_MERGED_UNKNOWN_REASON; mr = assignMrToCascadeResponsible(gitlabEventUUID, mr); } else { - Log.infof("GitlabEvent: '%s' | Merge failed for MR '!%d', status '%s'. Retrying... %d", gitlabEventUUID, mrNumber, mergeStatus, Math.abs(countDownWaitingMerged - MAX_RETRY_ATTEMPTS)); + Log.infof("GitlabEvent: '%s' | Merge failed for MR '!%d', status '%s'. Retrying... %d", gitlabEventUUID, mrNumber, mergeStatus, Math.abs(countDownWaitingMerged - maxRetryAttempts)); sleepSeconds(1); } } @@ -504,7 +506,7 @@ private String addMrPrefixPattern(String branchName) { private MergeRequest waitForMrReady(String gitlabEventUUID, MergeRequest mr) { boolean approverExists = gitlabApprover != null; - int countDown = MAX_RETRY_ATTEMPTS; + int countDown = maxRetryAttempts; String mrStatus = mr.getDetailedMergeStatus(); while (!isMrReady(mrStatus, approverExists, mr.getHasConflicts()) && countDown-- > 0) { sleepSeconds(1); diff --git a/src/test/java/com/unblu/ucascade/UcascadeTest.java b/src/test/java/com/unblu/ucascade/UcascadeTest.java index 1e1e941..d977ca2 100644 --- a/src/test/java/com/unblu/ucascade/UcascadeTest.java +++ b/src/test/java/com/unblu/ucascade/UcascadeTest.java @@ -692,7 +692,7 @@ void testMergeWithoutApprovals() throws Exception { .body(endsWith("\n}")) .body("created_auto_mr.ucascade_state", equalTo(MergeRequestUcascadeState.NOT_MERGED_MISSING_APPROVALS.name())); - verifyRequests(12); + verifyRequests(13); } @Test diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 181845a..f0dd065 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,2 +1,3 @@ gitlab.api.token=token_used_in_unit_tests_1234 -gitlab.api.token.approver=approver_token_used_in_unit_tests_1234 \ No newline at end of file +gitlab.api.token.approver=approver_token_used_in_unit_tests_1234 +ucascade.max-retry-attempts=1 \ No newline at end of file From c7d24b2ee624a5ee1ee1fac782f4b2606c7e72ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pereira?= Date: Mon, 26 Jan 2026 15:06:22 +0100 Subject: [PATCH 3/3] update documentation --- _documentation/src/docs/tech-docs/10_setup.adoc | 8 ++++++++ .../src/docs/tech-docs/30_technical-documentation.adoc | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/_documentation/src/docs/tech-docs/10_setup.adoc b/_documentation/src/docs/tech-docs/10_setup.adoc index 6ecbbde..afa9e49 100644 --- a/_documentation/src/docs/tech-docs/10_setup.adoc +++ b/_documentation/src/docs/tech-docs/10_setup.adoc @@ -60,6 +60,14 @@ Specify the api token value used when ucascade is approving a merge-request thro * No default value. * If not set, `ucascade` will merge the merge-requests *without* approving them first. If set, `ucascade` will approve the merge-request using that value. +==== Max retry attempts + +Specify the maximum number of retry attempts for operations that require waiting. + +* key: `ucascade.max-retry-attempts` +* default value: `60` +* Each retry waits 1 second before the next attempt, so a value of 60 means waiting up to 60 seconds. + === GitLab: Webhook Setup In the corresponding repository or group configure a https://docs.gitlab.com/ee/user/project/integrations/webhooks.html[Webhook] pointing to the location where ucascade is available: diff --git a/_documentation/src/docs/tech-docs/30_technical-documentation.adoc b/_documentation/src/docs/tech-docs/30_technical-documentation.adoc index 3eab34b..5865ac8 100644 --- a/_documentation/src/docs/tech-docs/30_technical-documentation.adoc +++ b/_documentation/src/docs/tech-docs/30_technical-documentation.adoc @@ -28,9 +28,10 @@ For a given merge request `merge` event, 3 different independent actions can be * `previous_auto_mr_merged`: if the merged-request event corresponds to the merge of an auto merge-request, and some other auto-merge requests are open between the same two branches, then the oldest auto merge request is merged. * `created_auto_mr`: if the merged-request event corresponds to the merge of any merge request that requires an auto merge-request to be created (to cascade the change to the next branch), it is created and potentially directly merged. With the "blocking" and "replay" endpoints, the returned object contains a `created_auto_mr.ucascade_state` field indicating if the action was successful or why it was not. It can take the values of: - ** MERGED: MR was created and merged succesfully + ** MERGED: MR was created and merged successfully ** NOT_MERGED_CONFLICTS: MR was created but not merged due to existing conflicts between source and target branches that must be manually resolved ** NOT_MERGED_CONCURRENT_MRS: MR was created but not merged due to another existing open merge request between the same source and target branches + ** NOT_MERGED_MISSING_APPROVALS: MR was created but not merged because it did not receive the required approvals within the configured timeout period ** NOT_MERGED_UNKNOWN_REASON: MR was created but not merged due to an unknown/unexpected reason * `existing_branch_deleted`: if the merged-request event corresponds to the **merge** of an auto merge-request and the source branch is still present, it gets deleted. @@ -62,6 +63,8 @@ This explains how ucascade determines if a new auto-merge request is created or include::{diagramsdir}/technical-workflow.puml[] ---- +Before attempting to merge, `ucascade` waits for the merge request to receive the required approvals. This is useful when approvals may arrive shortly after the MR is created (e.g., via automated approval workflows). The waiting period is controlled by the `ucascade.max-retry-attempts` configuration property (see xref:10_setup.adoc[Setup]). + During the final stage of the success case -- merging the MR -- `ucascade` verifies if that MR has any pipeline configured. If it has, the flag `merge_when_pipeline_succeeds` is set to `true`, meaning that the merge is only to happen after all its pipelines have finished successfully. Otherwise, if it doesn't have any pipeline, this flag is set to `false`, preventing GitLab to return a 405 error (see https://gitlab.com/gitlab-org/gitlab/-/issues/355455[issue #355455] for more details). ==== existing_branch_deleted