async def _update_merge_queue_summary( self, ctxt: context.Context, rule: "rules.EvaluatedRule", q: merge_train.Train, car: typing.Optional[merge_train.TrainCar], ) -> None: if car and car.creation_state == "updated" and not ctxt.closed: # NOTE(sileht): This car doesn't have tmp pull, so we have the # MERGE_QUEUE_SUMMARY and train reset here queue_rule_evaluated = await self.queue_rule.get_evaluated_queue_rule( ctxt.repository, ctxt.pull["base"]["ref"], [ctxt.pull_request], ctxt.log, ctxt.has_been_refreshed_by_timer(), ) await delayed_refresh.plan_next_refresh( ctxt, [queue_rule_evaluated], ctxt.pull_request ) unexpected_changes: typing.Optional[merge_train.UnexpectedChange] if await ctxt.has_been_synchronized_by_user() or await ctxt.is_behind: unexpected_changes = merge_train.UnexpectedUpdatedPullRequestChange( ctxt.pull["number"] ) status = check_api.Conclusion.PENDING ctxt.log.info( "train will be reset", unexpected_changes=unexpected_changes ) await q.reset(unexpected_changes) else: unexpected_changes = None status = await merge_base.get_rule_checks_status( ctxt.log, ctxt.repository, [ctxt.pull_request], queue_rule_evaluated, unmatched_conditions_return_failure=False, ) await car.update_state(status, queue_rule_evaluated) await car.update_summaries(status, unexpected_change=unexpected_changes) await q.save()
def load_conclusions( ctxt: context.Context, summary_check: typing.Optional[github_types.CachedGitHubCheckRun], ) -> typing.Dict[str, check_api.Conclusion]: line = load_conclusions_line(ctxt, summary_check) if line: return { name: check_api.Conclusion(conclusion) for name, conclusion in yaml.safe_load( base64.b64decode(line[5:-4].encode()).decode() ).items() } if not ctxt.has_been_opened(): ctxt.log.warning( "previous conclusion not found in summary", summary_check=summary_check, ) return {}
async def run_pending_commands_tasks(ctxt: context.Context) -> None: if ctxt.is_merge_queue_pr(): # We don't allow any command yet return pendings = set() async for comment in ctxt.client.items( f"{ctxt.base_url}/issues/{ctxt.pull['number']}/comments"): if comment["user"]["id"] != config.BOT_USER_ID: continue match = COMMAND_RESULT_MATCHER.search(comment["body"]) if match: command = match[1] state = match[2] if state == "pending": pendings.add(command) elif command in pendings: pendings.remove(command) for pending in pendings: await handle(ctxt, f"@Mergifyio {pending}", None, rerun=True)
def _should_be_cancel( self, ctxt: context.Context, rule: "rules.EvaluatedRule" ) -> bool: # It's closed, it's not going to change if ctxt.pull["state"] == "closed": return True if ctxt.have_been_synchronized(): return True need_look_at_checks = [] for condition in rule.missing_conditions: if condition.attribute_name.startswith( "check-" ) or condition.attribute_name.startswith("status-"): # TODO(sileht): Just return True here, no need to checks checks anymore, # this method is no more used by teh merge queue need_look_at_checks.append(condition) else: # something else does not match anymore return True if need_look_at_checks: if not ctxt.checks: return False states = [ state for name, state in ctxt.checks.items() for cond in need_look_at_checks if cond(FakePR(cond.attribute_name, name)) ] if not states: return False for state in states: if state in ("pending", None): return False return True
def run(self, ctxt: context.Context, rule: rules.EvaluatedRule) -> check_api.Result: if not config.GITHUB_APP: return check_api.Result( check_api.Conclusion.FAILURE, "Unavailable with GitHub Action", "Due to GitHub Action limitation, the `rebase` command is only available " "with the Mergify GitHub App.", ) if ctxt.is_behind: if ctxt.github_workflow_changed(): return check_api.Result( check_api.Conclusion.ACTION_REQUIRED, "Pull request must be rebased manually.", "GitHub App like Mergify are not allowed to rebase pull request where `.github/workflows` is changed.", ) output = branch_updater.pre_rebase_check(ctxt) if output: return output try: branch_updater.rebase_with_git(ctxt, self.config["bot_account"]) return check_api.Result( check_api.Conclusion.SUCCESS, "Branch has been successfully rebased", "", ) except ( branch_updater.AuthenticationFailure, branch_updater.BranchUpdateFailure, ) as e: return check_api.Result( check_api.Conclusion.FAILURE, "Branch rebase failed", str(e) ) else: return check_api.Result( check_api.Conclusion.SUCCESS, "Branch already up to date", "" )
async def pre_rebase_check(ctxt: context.Context) -> None: pre_update_check(ctxt) # If PR from a private fork but cannot be edited: # NOTE(jd): GitHub removed the ability to configure `maintainer_can_modify` on private # fork we which make rebase impossible if ( ctxt.pull_from_fork and ctxt.pull["base"]["repo"]["private"] and not ctxt.pull["maintainer_can_modify"] ): raise BranchUpdateFailure( "Mergify needs the permission to update the base branch of the pull request.\n" "GitHub does not allow a GitHub App to modify base branch for a private fork.\n" "You cannot `rebase` a pull request from a private fork.", title="Pull request can't be updated with latest base branch changes", ) elif not ctxt.can_change_github_workflow() and await ctxt.github_workflow_changed(): raise BranchUpdateFailure( "The new Mergify permissions must be accepted to rebase pull request with `.github/workflows` changes.\n" "You can accept them at https://dashboard.mergify.com/.\n" "In the meantime, this pull request must be rebased manually.", title="Pull request can't be updated with latest base branch changes", )
async def run(self, ctxt: context.Context, rule: "rules.EvaluatedRule") -> check_api.Result: subscription_status = await self._subscription_status(ctxt) if subscription_status: return subscription_status if self.config["method"] == "fast-forward": if self.config["update_method"] != "rebase": return check_api.Result( check_api.Conclusion.FAILURE, f"`update_method: {self.config['update_method']}` is not compatible with fast-forward merge method", "`update_method` must be set to `rebase`.", ) elif self.config["commit_message_template"] is not None: return check_api.Result( check_api.Conclusion.FAILURE, "Commit message can't be changed with fast-forward merge method", "`commit_message_template` must not be set if `method: fast-forward` is set.", ) elif self.queue_rule.config["batch_size"] > 1: return check_api.Result( check_api.Conclusion.FAILURE, "batch_size > 1 is not compatible with fast-forward merge method", "The merge `method` or the queue configuration must be updated.", ) elif self.queue_rule.config["speculative_checks"] > 1: return check_api.Result( check_api.Conclusion.FAILURE, "speculative_checks > 1 is not compatible with fast-forward merge method", "The merge `method` or the queue configuration must be updated.", ) elif not self.queue_rule.config["allow_inplace_checks"]: return check_api.Result( check_api.Conclusion.FAILURE, "allow_inplace_checks=False is not compatible with fast-forward merge method", "The merge `method` or the queue configuration must be updated.", ) protection = await ctxt.repository.get_branch_protection( ctxt.pull["base"]["ref"]) if (protection and "required_status_checks" in protection and "strict" in protection["required_status_checks"] and protection["required_status_checks"]["strict"]): if self.queue_rule.config["batch_size"] > 1: return check_api.Result( check_api.Conclusion.FAILURE, "batch_size > 1 is not compatible with branch protection setting", "The branch protection setting `Require branches to be up to date before merging` must be unset.", ) elif self.queue_rule.config["speculative_checks"] > 1: return check_api.Result( check_api.Conclusion.FAILURE, "speculative_checks > 1 is not compatible with branch protection setting", "The branch protection setting `Require branches to be up to date before merging` must be unset.", ) # FIXME(sileht): we should use the computed update_bot_account in TrainCar.update_pull(), # not the original one try: await action_utils.render_bot_account( ctxt, self.config["update_bot_account"], option_name="update_bot_account", required_feature=subscription.Features.MERGE_BOT_ACCOUNT, missing_feature_message= "Queue with `update_bot_account` set is unavailable", ) except action_utils.RenderBotAccountFailure as e: return check_api.Result(e.status, e.title, e.reason) try: merge_bot_account = await action_utils.render_bot_account( ctxt, self.config["merge_bot_account"], option_name="merge_bot_account", required_feature=subscription.Features.MERGE_BOT_ACCOUNT, missing_feature_message= "Queue with `merge_bot_account` set is unavailable", # NOTE(sileht): we don't allow admin, because if branch protection are # enabled, but not enforced on admins, we may bypass them required_permissions=["write", "maintain"], ) except action_utils.RenderBotAccountFailure as e: return check_api.Result(e.status, e.title, e.reason) q = await merge_train.Train.from_context(ctxt) car = q.get_car(ctxt) await self._update_merge_queue_summary(ctxt, rule, q, car) if ctxt.user_refresh_requested() or ctxt.admin_refresh_requested(): # NOTE(sileht): user ask a refresh, we just remove the previous state of this # check and the method _should_be_queued will become true again :) check = await ctxt.get_engine_check_run( constants.MERGE_QUEUE_SUMMARY_NAME) if check and check_api.Conclusion(check["conclusion"]) not in [ check_api.Conclusion.SUCCESS, check_api.Conclusion.PENDING, check_api.Conclusion.NEUTRAL, ]: await check_api.set_check_run( ctxt, constants.MERGE_QUEUE_SUMMARY_NAME, check_api.Result( check_api.Conclusion.PENDING, "The pull request has been refreshed and is going to be re-embarked soon", "", ), ) self._set_effective_priority(ctxt) result = await self.merge_report(ctxt) if result is None: if await self._should_be_queued(ctxt, q): await q.add_pull( ctxt, typing.cast(queue.PullQueueConfig, self.config)) try: qf = await freeze.QueueFreeze.get(ctxt.repository, self.config["name"]) if await self._should_be_merged(ctxt, q, qf): result = await self._merge(ctxt, rule, q, merge_bot_account) else: result = await self.get_queue_status(ctxt, rule, q, qf) except Exception: await q.remove_pull(ctxt) raise else: result = await self.get_unqueue_status(ctxt, q) if result.conclusion is not check_api.Conclusion.PENDING: await q.remove_pull(ctxt) # NOTE(sileht): Only refresh if the car still exists and is the same as # before we run the action new_car = q.get_car(ctxt) if (car and car.queue_pull_request_number is not None and new_car and new_car.creation_state == "created" and new_car.queue_pull_request_number is not None and new_car.queue_pull_request_number == car.queue_pull_request_number and self.need_draft_pull_request_refresh() and not ctxt.has_been_only_refreshed()): # NOTE(sileht): It's not only refreshed, so we need to # update the associated transient pull request. # This is mandatory to filter out refresh to avoid loop # of refreshes between this PR and the transient one. await utils.send_pull_refresh( ctxt.repository.installation.redis.stream, ctxt.pull["base"]["repo"], pull_request_number=new_car.queue_pull_request_number, action="internal", source="forward from queue action (run)", ) return result
async def handle(queue_rules: rules.QueueRules, ctxt: context.Context) -> None: # FIXME: Maybe create a command to force the retesting to put back the PR in the queue? train = await merge_train.Train.from_context(ctxt) car = train.get_car_by_tmp_pull(ctxt) if not car: if ctxt.closed: ctxt.log.info( "train car temporary pull request has been closed", sources=ctxt.sources ) else: ctxt.log.warning( "train car not found for an opened merge queue pull request", sources=ctxt.sources, ) return if car.checks_conclusion != check_api.Conclusion.PENDING and ctxt.closed: ctxt.log.info( "train car temporary pull request has been closed", sources=ctxt.sources ) return if car.queue_pull_request_number is None: raise RuntimeError( "Got draft pull request event on car without queue_pull_request_number" ) ctxt.log.info( "handling train car temporary pull request event", sources=ctxt.sources, gh_pulls_queued=[ ep.user_pull_request_number for ep in car.still_queued_embarked_pulls ], ) queue_name = car.still_queued_embarked_pulls[0].config["name"] try: queue_rule = queue_rules[queue_name] except KeyError: ctxt.log.warning( "queue_rule not found for this train car", gh_pulls_queued=[ ep.user_pull_request_number for ep in car.still_queued_embarked_pulls ], queue_rules=queue_rules, queue_name=queue_name, ) return pull_requests = await car.get_pull_requests_to_evaluate() evaluated_queue_rule = await queue_rule.get_evaluated_queue_rule( ctxt.repository, ctxt.pull["base"]["ref"], pull_requests, ctxt.log, ctxt.has_been_refreshed_by_timer(), ) for pull_request in pull_requests: await delayed_refresh.plan_next_refresh( ctxt, [evaluated_queue_rule], pull_request ) if not ctxt.sources: # NOTE(sileht): Only comment/command, don't need to go further return None unexpected_changes: typing.Optional[merge_train.UnexpectedChange] = None if await have_unexpected_draft_pull_request_changes(ctxt, car): unexpected_changes = merge_train.UnexpectedDraftPullRequestChange( car.queue_pull_request_number ) else: current_base_sha = await train.get_base_sha() if not await train.is_synced_with_the_base_branch(current_base_sha): unexpected_changes = merge_train.UnexpectedBaseBranchChange( current_base_sha ) if unexpected_changes is None: real_status = status = await checks_status.get_rule_checks_status( ctxt.log, ctxt.repository, pull_requests, evaluated_queue_rule, unmatched_conditions_return_failure=False, ) if real_status == check_api.Conclusion.FAILURE and ( not car.has_previous_car_status_succeeded() or len(car.initial_embarked_pulls) != 1 ): # NOTE(sileht): we can't set it as failed as we don't known # yet which pull request is responsible for the failure. # * one of the batch ? # * one of the parent car ? status = check_api.Conclusion.PENDING else: real_status = status = check_api.Conclusion.PENDING ctxt.log.info( "train car temporary pull request evaluation", gh_pull_queued=[ ep.user_pull_request_number for ep in car.still_queued_embarked_pulls ], evaluated_queue_rule=evaluated_queue_rule.conditions.get_summary(), unexpected_changes=unexpected_changes, temporary_status=status, real_status=real_status, event_types=[se["event_type"] for se in ctxt.sources], ) await car.update_state(real_status, evaluated_queue_rule) await car.update_summaries(status, unexpected_change=unexpected_changes) await train.save() if unexpected_changes: ctxt.log.info( "train will be reset", gh_pull_queued=[ ep.user_pull_request_number for ep in car.still_queued_embarked_pulls ], unexpected_changes=unexpected_changes, ) await train.reset(unexpected_changes) await ctxt.client.post( f"{ctxt.base_url}/issues/{ctxt.pull['number']}/comments", json={ "body": f"This pull request has unexpected changes: {unexpected_changes}. The whole train will be reset." }, )
def _merge( self, ctxt: context.Context, rule: rules.Rule, missing_conditions: typing.List[filter.Filter], q: queue.Queue, ) -> check_api.Result: if self.config["method"] != "rebase" or ctxt.pull["rebaseable"]: method = self.config["method"] elif self.config["rebase_fallback"]: method = self.config["rebase_fallback"] else: return check_api.Result( check_api.Conclusion.ACTION_REQUIRED, "Automatic rebasing is not possible, manual intervention required", "", ) data = {} try: commit_title_and_message = self._get_commit_message( ctxt.pull_request, self.config["commit_message"], ) except context.RenderTemplateFailure as rmf: return check_api.Result( check_api.Conclusion.ACTION_REQUIRED, "Invalid commit message", str(rmf), ) if commit_title_and_message is not None: title, message = commit_title_and_message if title: data["commit_title"] = title if message: data["commit_message"] = message data["sha"] = ctxt.pull["head"]["sha"] data["merge_method"] = method bot_account = self.config["merge_bot_account"] if bot_account: oauth_token = ctxt.subscription.get_token_for(bot_account) if not oauth_token: return check_api.Result( check_api.Conclusion.FAILURE, f"Unable to rebase: user `{bot_account}` is unknown. ", f"Please make sure `{bot_account}` has logged in Mergify dashboard.", ) else: oauth_token = None try: ctxt.client.put( f"{ctxt.base_url}/pulls/{ctxt.pull['number']}/merge", oauth_token=oauth_token, # type: ignore json=data, ) except http.HTTPClientSideError as e: # pragma: no cover ctxt.update() if ctxt.pull["merged"]: ctxt.log.info("merged in the meantime") else: return self._handle_merge_error(e, ctxt, rule, missing_conditions, q) else: ctxt.update() ctxt.log.info("merged") result = helpers.merge_report(ctxt, self.config["strict"]) if result: return result else: return check_api.Result( check_api.Conclusion.FAILURE, "Unexpected after merge pull request state", "The pull request have been merged, but GitHub API still report it open", )
async def run_actions( ctxt: context.Context, match: rules.RulesEvaluator, checks: typing.Dict[str, github_types.GitHubCheckRun], previous_conclusions: typing.Dict[str, check_api.Conclusion], ) -> typing.Dict[str, check_api.Conclusion]: """ What action.run() and action.cancel() return should be reworked a bit. Currently the meaning is not really clear, it could be: - None - (succeed but no dedicated report is posted with check api - (None, "<title>", "<summary>") - (action is pending, for merge/backport/...) - ("success", "<title>", "<summary>") - ("failure", "<title>", "<summary>") - ("neutral", "<title>", "<summary>") - ("cancelled", "<title>", "<summary>") """ user_refresh_requested = ctxt.user_refresh_requested() admin_refresh_requested = ctxt.admin_refresh_requested() actions_ran = set() conclusions = {} # NOTE(sileht): We put first rules with missing conditions to do cancellation first. # In case of a canceled merge action and another that need to be run. We want first # to remove the PR from the queue and then add it back with the new config and not the # reverse matching_rules = sorted(match.matching_rules, key=lambda rule: len(rule.missing_conditions) == 0) method_name: typing.Literal["run", "cancel"] for rule in matching_rules: for action, action_obj in rule.actions.items(): check_name = f"Rule: {rule.name} ({action})" done_by_another_action = action_obj.only_once and action in actions_ran action_rule = await action_obj.get_rule(ctxt) if rule.missing_conditions or action_rule.missing_conditions: method_name = "cancel" expected_conclusions = [ check_api.Conclusion.NEUTRAL, check_api.Conclusion.CANCELLED, ] else: method_name = "run" expected_conclusions = [ check_api.Conclusion.SUCCESS, check_api.Conclusion.FAILURE, ] actions_ran.add(action) previous_conclusion = get_previous_conclusion( previous_conclusions, check_name, checks) need_to_be_run = ( action_obj.always_run or admin_refresh_requested or (user_refresh_requested and previous_conclusion == check_api.Conclusion.FAILURE) or previous_conclusion not in expected_conclusions) # TODO(sileht): refactor it to store the whole report in the check summary, # not just the conclusions if not need_to_be_run: report = check_api.Result(previous_conclusion, "Already in expected state", "") message = "ignored, already in expected state" elif done_by_another_action: # NOTE(sileht) We can't run two action merge for example, # This assumes the action produce a report report = check_api.Result( check_api.Conclusion.SUCCESS, f"Another {action} action already ran", "", ) message = "ignored, another has already been run" else: # NOTE(sileht): check state change so we have to run "run" or "cancel" report = await exec_action( method_name, rule, action, ctxt, ) message = "executed" if (report and report.conclusion is not check_api.Conclusion.PENDING and method_name == "run"): statsd.increment("engine.actions.count", tags=[f"name:{action}"]) if report: if need_to_be_run and (not action_obj.silent_report or report.conclusion not in ( check_api.Conclusion.SUCCESS, check_api.Conclusion.CANCELLED, check_api.Conclusion.PENDING, )): external_id = (check_api.USER_CREATED_CHECKS if action_obj.allow_retrigger_mergify else None) try: await check_api.set_check_run( ctxt, check_name, report, external_id=external_id, ) except Exception as e: if exceptions.should_be_ignored(e): ctxt.log.info("Fail to post check `%s`", check_name, exc_info=True) elif exceptions.need_retry(e): raise else: ctxt.log.error("Fail to post check `%s`", check_name, exc_info=True) conclusions[check_name] = report.conclusion else: # NOTE(sileht): action doesn't have report (eg: # comment/request_reviews/..) So just assume it succeed ctxt.log.error("action must return a conclusion", action=action) conclusions[check_name] = expected_conclusions[0] ctxt.log.info( "action evaluation: `%s` %s: %s/%s -> %s", action, message, method_name, previous_conclusion.value, conclusions[check_name].value, report=report, previous_conclusion=previous_conclusion.value, conclusion=conclusions[check_name].value, action=action, check_name=check_name, event_types=[se["event_type"] for se in ctxt.sources], ) return conclusions
async def run( ctxt: context.Context, sources: typing.List[context.T_PayloadEventSource], ) -> typing.Optional[check_api.Result]: LOG.debug("engine get context") ctxt.log.debug("engine start processing context") issue_comment_sources: typing.List[T_PayloadEventIssueCommentSource] = [] for source in sources: if source["event_type"] == "issue_comment": issue_comment_sources.append( typing.cast(T_PayloadEventIssueCommentSource, source)) else: ctxt.sources.append(source) permissions_need_to_be_updated = github_app.permissions_need_to_be_updated( ctxt.repository.installation.installation) if permissions_need_to_be_updated: return check_api.Result( check_api.Conclusion.FAILURE, title="Required GitHub permissions are missing.", summary="You can accept them at https://dashboard.mergify.com/", ) if ctxt.pull["base"]["repo"]["private"]: if not ctxt.subscription.has_feature( subscription.Features.PRIVATE_REPOSITORY): ctxt.log.info("mergify disabled: private repository", reason=ctxt.subscription.reason) return check_api.Result( check_api.Conclusion.FAILURE, title="Mergify is disabled", summary=ctxt.subscription.reason, ) else: if not ctxt.subscription.has_feature( subscription.Features.PUBLIC_REPOSITORY): ctxt.log.info("mergify disabled: public repository", reason=ctxt.subscription.reason) return check_api.Result( check_api.Conclusion.FAILURE, title="Mergify is disabled", summary=ctxt.subscription.reason, ) config_file = await ctxt.repository.get_mergify_config_file() try: ctxt.configuration_changed = await _check_configuration_changes( ctxt, config_file) except MultipleConfigurationFileFound as e: files = "\n * " + "\n * ".join(f["path"] for f in e.files) # NOTE(sileht): This replaces the summary, so we will may lost the # state of queue/comment action. But since we can't choice which config # file we need to use... we can't do much. return check_api.Result( check_api.Conclusion.FAILURE, title=constants.CONFIGURATION_MUTIPLE_FOUND_SUMMARY_TITLE, summary= f"You must keep only one of these configuration files in the repository: {files}", ) # BRANCH CONFIGURATION CHECKING try: mergify_config = await ctxt.repository.get_mergify_config() except rules.InvalidRules as e: # pragma: no cover ctxt.log.info( "The Mergify configuration is invalid", summary=str(e), annotations=e.get_annotations(e.filename), ) # Not configured, post status check with the error message for s in ctxt.sources: if s["event_type"] == "pull_request": event = typing.cast(github_types.GitHubEventPullRequest, s["data"]) if event["action"] in ("opened", "synchronize"): return check_api.Result( check_api.Conclusion.FAILURE, title="The current Mergify configuration is invalid", summary=str(e), annotations=e.get_annotations(e.filename), ) return None ctxt.log.debug("engine run pending commands") await commands_runner.run_pending_commands_tasks(ctxt, mergify_config) if issue_comment_sources: ctxt.log.debug("engine handle commands") for ic_source in issue_comment_sources: await commands_runner.handle( ctxt, mergify_config, ic_source["data"]["comment"]["body"], ic_source["data"]["comment"]["user"], ) await _ensure_summary_on_head_sha(ctxt) summary = await ctxt.get_engine_check_run(constants.SUMMARY_NAME) if (summary and summary["external_id"] is not None and summary["external_id"] != "" and summary["external_id"] != str(ctxt.pull["number"])): other_ctxt = await ctxt.repository.get_pull_request_context( github_types.GitHubPullRequestNumber(int(summary["external_id"]))) # NOTE(sileht): allow to override the summary of another pull request # only if this one is closed, but this can still confuse users as the # check-runs created by merge/queue action will not be cleaned. # TODO(sileht): maybe cancel all other mergify engine check-runs in this case? if not other_ctxt.closed: # TODO(sileht): try to report that without check-runs/statuses to the user # and without spamming him with comment ctxt.log.info( "sha collision detected between pull requests", other_pull=summary["external_id"], ) return None if not ctxt.has_been_opened() and summary is None: ctxt.log.warning( "the pull request doesn't have a summary", head_sha=ctxt.pull["head"]["sha"], ) ctxt.log.debug("engine handle actions") if ctxt.is_merge_queue_pr(): return await queue_runner.handle(mergify_config["queue_rules"], ctxt) else: return await actions_runner.handle( mergify_config["pull_request_rules"], ctxt)
async def _edit_draft_state( self, ctxt: context.Context, draft_converted: bool ) -> check_api.Result: if draft_converted: expected_state = True current_state = "draft" mutation = "convertPullRequestToDraft" else: expected_state = False current_state = "ready for review" mutation = "markPullRequestReadyForReview" if ctxt.pull["draft"] == expected_state: return check_api.Result( check_api.Conclusion.SUCCESS, f"Pull request is already {current_state}.", "", ) try: bot_account = await action_utils.render_bot_account( ctxt, self.config["bot_account"], required_feature=subscription.Features.BOT_ACCOUNT, missing_feature_message=f"{current_state} with `bot_account` set is disabled", required_permissions=[], ) except action_utils.RenderBotAccountFailure as e: return check_api.Result(e.status, e.title, e.reason) try: tokens = await user_tokens.UserTokens.select_users_for(ctxt, bot_account) except user_tokens.UserTokensUserNotFound as e: return check_api.Result( check_api.Conclusion.FAILURE, "Fail to convert pull request", e.reason ) mutation = f""" mutation {{ {mutation}(input:{{pullRequestId: "{ctxt.pull['node_id']}"}}) {{ pullRequest {{ isDraft }} }} }} """ try: await ctxt.client.graphql_post( mutation, oauth_token=tokens[0]["oauth_access_token"], ) except github.GraphqlError as e: if "Field 'convertPullRequestToDraft' doesn't exist" in e.message: return check_api.Result( check_api.Conclusion.FAILURE, "Converting pull request to draft requires GHES >= 3.2", "", ) ctxt.log.error( "GraphQL API call failed, unable to convert PR.", current_state=current_state, response=e.message, ) return check_api.Result( check_api.Conclusion.FAILURE, f"GraphQL API call failed, pull request wasn't converted to {current_state}.", "", ) ctxt.pull["draft"] = expected_state return check_api.Result( check_api.Conclusion.SUCCESS, f"Pull request successfully converted to {current_state}", "", )
async def run_pending_commands_tasks( ctxt: context.Context, mergify_config: rules.MergifyConfig) -> None: if ctxt.is_merge_queue_pr(): # We don't allow any command yet return pendings = set() async for comment in ctxt.client.items( f"{ctxt.base_url}/issues/{ctxt.pull['number']}/comments", resource_name="comments", page_limit=20, ): if comment["user"]["id"] != config.BOT_USER_ID: continue # Old format match = COMMAND_RESULT_MATCHER_OLD.search(comment["body"]) if match: command = match[1] state = match[2] if state == "pending": pendings.add(command) elif command in pendings: pendings.remove(command) continue # New format match = COMMAND_RESULT_MATCHER.search(comment["body"]) if match is None: continue try: payload = json.loads(match[1]) except Exception: LOG.warning("Unable to load command payload: %s", match[1]) continue command = payload.get("command") if not command: continue conclusion_str = payload.get("conclusion") try: conclusion = check_api.Conclusion(conclusion_str) except ValueError: LOG.error("Unable to load conclusions %s", conclusion_str) continue if conclusion == check_api.Conclusion.PENDING: pendings.add(command) elif command in pendings: try: pendings.remove(command) except KeyError: LOG.error("Unable to remove command: %s", command) for pending in pendings: await handle(ctxt, mergify_config, f"@Mergifyio {pending}", None, rerun=True)
async def cancel( self, ctxt: context.Context, rule: "rules.EvaluatedRule" ) -> check_api.Result: # FIXME(sileht): we should use the computed update_bot_account in TrainCar.update_pull(), # not the original one try: await action_utils.render_bot_account( ctxt, self.config["update_bot_account"], option_name="update_bot_account", required_feature=subscription.Features.MERGE_BOT_ACCOUNT, missing_feature_message="Queue with `update_bot_account` set is unavailable", ) except action_utils.RenderBotAccountFailure as e: return check_api.Result(e.status, e.title, e.reason) self._set_effective_priority(ctxt) q = await merge_train.Train.from_context(ctxt) car = q.get_car(ctxt) await self._update_merge_queue_summary(ctxt, rule, q, car) result = await self.merge_report(ctxt) if result is None: # We just rebase the pull request, don't cancel it yet if CIs are # running. The pull request will be merged if all rules match again. # if not we will delete it when we received all CIs termination if await self._should_be_queued(ctxt, q): if await self._should_be_cancel(ctxt, rule, q): result = actions.CANCELLED_CHECK_REPORT else: result = await self.get_queue_status(ctxt, rule, q) else: result = await self.get_unqueue_status(ctxt, q) if result.conclusion is not check_api.Conclusion.PENDING: await q.remove_pull(ctxt) # The car may have been removed newcar = q.get_car(ctxt) # NOTE(sileht): Only refresh if the car still exists if ( newcar and newcar.creation_state == "created" and newcar.queue_pull_request_number is not None and self.need_draft_pull_request_refresh() and not ctxt.has_been_only_refreshed() ): # NOTE(sileht): It's not only refreshed, so we need to # update the associated transient pull request. # This is mandatory to filter out refresh to avoid loop # of refreshes between this PR and the transient one. with utils.yaaredis_for_stream() as redis_stream: await utils.send_pull_refresh( ctxt.repository.installation.redis, redis_stream, ctxt.pull["base"]["repo"], pull_request_number=newcar.queue_pull_request_number, action="internal", source="forward from queue action (cancel)", ) return result
async def run_actions( ctxt: context.Context, match: rules.RulesEvaluator, checks: typing.Dict[str, github_types.CachedGitHubCheckRun], previous_conclusions: typing.Dict[str, check_api.Conclusion], ) -> typing.Dict[str, check_api.Conclusion]: """ What action.run() and action.cancel() return should be reworked a bit. Currently the meaning is not really clear, it could be: - None - (succeed but no dedicated report is posted with check api - (None, "<title>", "<summary>") - (action is pending, for merge/backport/...) - ("success", "<title>", "<summary>") - ("failure", "<title>", "<summary>") - ("neutral", "<title>", "<summary>") - ("cancelled", "<title>", "<summary>") """ user_refresh_requested = ctxt.user_refresh_requested() admin_refresh_requested = ctxt.admin_refresh_requested() actions_ran = set() conclusions = {} # NOTE(sileht): We put first rules with missing conditions to do cancellation first. # In case of a canceled merge action and another that need to be run. We want first # to remove the PR from the queue and then add it back with the new config and not the # reverse matching_rules = sorted( match.matching_rules, key=lambda rule: rule.conditions.match ) method_name: typing.Literal["run", "cancel"] for rule in matching_rules: for action, action_obj in rule.actions.items(): check_name = rule.get_check_name(action) done_by_another_action = ( actions.ActionFlag.DISALLOW_RERUN_ON_OTHER_RULES in action_obj.flags and action in actions_ran ) if ( not rule.conditions.match or rule.disabled is not None or ( ctxt.configuration_changed and actions.ActionFlag.ALLOW_ON_CONFIGURATION_CHANGED not in action_obj.flags ) ): method_name = "cancel" expected_conclusions = [ check_api.Conclusion.NEUTRAL, check_api.Conclusion.CANCELLED, ] else: method_name = "run" expected_conclusions = [ check_api.Conclusion.SUCCESS, check_api.Conclusion.FAILURE, ] actions_ran.add(action) previous_conclusion = get_previous_conclusion( previous_conclusions, check_name, checks ) need_to_be_run = ( actions.ActionFlag.ALWAYS_RUN in action_obj.flags or ( actions.ActionFlag.SUCCESS_IS_FINAL_STATE in action_obj.flags and previous_conclusion == check_api.Conclusion.SUCCESS ) or admin_refresh_requested or ( user_refresh_requested and previous_conclusion == check_api.Conclusion.FAILURE ) or previous_conclusion not in expected_conclusions ) # TODO(sileht): refactor it to store the whole report in the check summary, # not just the conclusions if not need_to_be_run: report = check_api.Result( previous_conclusion, "Already in expected state", "" ) message = "ignored, already in expected state" elif done_by_another_action: # NOTE(sileht) We can't run two action merge for example, # This assumes the action produce a report report = check_api.Result( check_api.Conclusion.SUCCESS, f"Another {action} action already ran", "", ) message = "ignored, another has already been run" else: with ddtrace.tracer.trace( f"action.{action}", span_type="worker", resource=str(ctxt), ) as span: # NOTE(sileht): check state change so we have to run "run" or "cancel" report = await exec_action( method_name, rule, action, ctxt, ) span.set_tags({"conclusion": str(report.conclusion)}) message = "executed" conclusions[check_name] = report.conclusion if ( report.conclusion is not check_api.Conclusion.PENDING and method_name == "run" ): statsd.increment("engine.actions.count", tags=[f"name:{action}"]) if need_to_be_run and ( actions.ActionFlag.ALWAYS_SEND_REPORT in action_obj.flags or report.conclusion not in ( check_api.Conclusion.SUCCESS, check_api.Conclusion.CANCELLED, check_api.Conclusion.PENDING, ) ): external_id = ( check_api.USER_CREATED_CHECKS if actions.ActionFlag.ALLOW_RETRIGGER_MERGIFY in action_obj.flags else None ) try: await check_api.set_check_run( ctxt, check_name, report, external_id=external_id, ) except Exception as e: if exceptions.should_be_ignored(e): ctxt.log.info( "Fail to post check `%s`", check_name, exc_info=True ) elif exceptions.need_retry(e): raise else: ctxt.log.error( "Fail to post check `%s`", check_name, exc_info=True ) ctxt.log.info( "action evaluation: `%s` %s: %s/%s -> %s", action, message, method_name, previous_conclusion.value, conclusions[check_name].value, report=report, previous_conclusion=previous_conclusion.value, conclusion=conclusions[check_name].value, action=action, check_name=check_name, event_types=[se["event_type"] for se in ctxt.sources], ) return conclusions
async def handle( ctxt: context.Context, mergify_config: rules.MergifyConfig, comment: str, user: typing.Optional[github_types.GitHubAccount], rerun: bool = False, ) -> None: # Run command only if this is a pending task or if user have permission to do it. if not rerun and not user: raise RuntimeError("user must be set if rerun is false") def log(comment_out: str, result: typing.Optional[check_api.Result] = None) -> None: ctxt.log.info( "ran command", user_login=None if user is None else user["login"], rerun=rerun, comment_in=comment, comment_out=comment_out, result=result, ) if "@mergifyio" in comment.lower(): # @mergify have been used instead footer = "" else: footer = "\n\n" + WRONG_ACCOUNT_MESSAGE if user: if ( user["id"] != ctxt.pull["user"]["id"] and user["id"] != config.BOT_USER_ID and not await ctxt.repository.has_write_permission(user) ): message = f"@{user['login']} is not allowed to run commands" log(message) await post_comment(ctxt, message + footer) return command = load_command(mergify_config, comment) if not command: message = UNKNOWN_COMMAND_MESSAGE log(message) await post_comment(ctxt, message + footer) return if ( ctxt.configuration_changed and not command.action.can_be_used_on_configuration_change ): message = CONFIGURATION_CHANGE_MESSAGE log(message) await post_comment(ctxt, message + footer) return if command.name != "refresh" and ctxt.is_merge_queue_pr(): log(MERGE_QUEUE_COMMAND_MESSAGE) await post_comment(ctxt, MERGE_QUEUE_COMMAND_MESSAGE + footer) return result, message = await run_command(ctxt, command, user) if result.conclusion is check_api.Conclusion.PENDING and rerun: log("action still pending", result) return log(message, result) await post_comment(ctxt, message + footer)
async def merge_report( self, ctxt: context.Context, ) -> typing.Optional[check_api.Result]: if ctxt.pull["draft"]: conclusion = check_api.Conclusion.PENDING title = "Draft flag needs to be removed" summary = "" elif ctxt.pull["merged"]: if ctxt.pull["merged_by"] is None: mode = "somehow" elif ctxt.pull["merged_by"]["login"] == config.BOT_USER_LOGIN: mode = "automatically" else: mode = "manually" conclusion = check_api.Conclusion.SUCCESS title = f"The pull request has been merged {mode}" summary = f"The pull request has been merged {mode} at *{ctxt.pull['merge_commit_sha']}*" elif ctxt.closed: conclusion = check_api.Conclusion.CANCELLED title = "The pull request has been closed manually" summary = "" # NOTE(sileht): Take care of all branch protection state elif ctxt.pull["mergeable_state"] == "dirty": conclusion = check_api.Conclusion.CANCELLED title = "Merge conflict needs to be solved" summary = "" elif ctxt.pull["mergeable_state"] == "unknown": conclusion = check_api.Conclusion.FAILURE title = "Pull request state reported as `unknown` by GitHub" summary = "" # FIXME(sileht): We disable this check as github wrongly report # mergeable_state == blocked sometimes. The workaround is to try to merge # it and if that fail we checks for blocking state. # elif ctxt.pull["mergeable_state"] == "blocked": # conclusion = "failure" # title = "Branch protection settings are blocking automatic merging" # summary = "" elif (await self._is_branch_protection_linear_history_enabled(ctxt) and self.config["method"] == "merge"): conclusion = check_api.Conclusion.FAILURE title = "Branch protection setting 'linear history' conflicts with Mergify configuration" summary = "Branch protection setting 'linear history' works only if `method: squash` or `method: rebase`." elif (not ctxt.can_change_github_workflow() and await ctxt.github_workflow_changed()): conclusion = check_api.Conclusion.ACTION_REQUIRED title = "Pull request must be merged manually" summary = """The new Mergify permissions must be accepted to merge pull request with `.github/workflows` changes.\n You can accept them at https://dashboard.mergify.com/\n \n In the meantime, the pull request must be merged manually." """ # NOTE(sileht): remaining state "behind, clean, unstable, has_hooks # are OK for us else: return None return check_api.Result(conclusion, title, summary)
async def run( self, ctxt: context.Context, rule: rules.EvaluatedRule ) -> check_api.Result: if ( not ctxt.can_change_github_workflow() and await ctxt.github_workflow_changed() ): return check_api.Result( check_api.Conclusion.FAILURE, self.FAILURE_MESSAGE, "The new Mergify permissions must be accepted to create pull request with `.github/workflows` changes.\n" "You can accept them at https://dashboard.mergify.com/", ) template_result = await self._verify_template(ctxt) if template_result is not None: return template_result try: bot_account = await action_utils.render_bot_account( ctxt, self.config["bot_account"], required_feature=subscription.Features.BOT_ACCOUNT, missing_feature_message=f"{self.KIND.capitalize()} with `bot_account` set is unavailable", ) except action_utils.RenderBotAccountFailure as e: return check_api.Result(e.status, e.title, e.reason) branches: typing.List[github_types.GitHubRefType] = self.config["branches"] if self.config["regexes"]: branches.extend( [ branch["name"] async for branch in typing.cast( typing.AsyncGenerator[github_types.GitHubBranch, None], ctxt.client.items( f"{ctxt.base_url}/branches", resource_name="branches", page_limit=10, ), ) if any( map( lambda regex: regex.match(branch["name"]), self.config["regexes"], ) ) ] ) if len(branches) == 0: return check_api.Result( check_api.Conclusion.FAILURE, self.FAILURE_MESSAGE, "No destination branches found", ) results = [ await self._copy(ctxt, branch_name, bot_account) for branch_name in branches ] # Pick the first status as the final_status conclusion = results[0][0] for r in results[1:]: if r[0] == check_api.Conclusion.FAILURE: conclusion = check_api.Conclusion.FAILURE # If we have a failure, everything is set to fail break elif r[0] == check_api.Conclusion.SUCCESS: # If it was None, replace with success # Keep checking for a failure just in case conclusion = check_api.Conclusion.SUCCESS if conclusion == check_api.Conclusion.SUCCESS: message = self.SUCCESS_MESSAGE elif conclusion == check_api.Conclusion.FAILURE: message = self.FAILURE_MESSAGE else: message = "Pending" return check_api.Result( conclusion, message, "\n".join(f"* {detail}" for detail in (r[1] for r in results)), )
async def run( self, ctxt: context.Context, rule: rules.EvaluatedRule ) -> check_api.Result: if self.config["message"] is None: message_raw = DEFAULT_MESSAGE[self.config["when"]] else: message_raw = typing.cast(str, self.config["message"]) try: message = await ctxt.pull_request.render_template(message_raw) except context.RenderTemplateFailure as rmf: return check_api.Result( check_api.Conclusion.FAILURE, "Invalid dismiss reviews message", str(rmf), ) if self.config["when"] == WHEN_SYNCHRONIZE and not ctxt.has_been_synchronized(): return check_api.Result( check_api.Conclusion.SUCCESS, "Nothing to do, pull request has not been synchronized", "", ) # FIXME(sileht): Currently sender id is not the bot by the admin # user that enroll the repo in Mergify, because branch_updater uses # his access_token instead of the Mergify installation token. # As workaround we track in redis merge commit id # This is only true for method="rebase" if ( self.config["when"] == WHEN_SYNCHRONIZE and not await ctxt.has_been_synchronized_by_user() ): return check_api.Result( check_api.Conclusion.SUCCESS, "Updated by Mergify, ignoring", "" ) requested_reviewers_login = [ rr["login"] for rr in ctxt.pull["requested_reviewers"] ] to_dismiss = set() for review in (await ctxt.consolidated_reviews())[1]: conf = self.config.get(review["state"].lower(), False) if conf is True: to_dismiss.add(review["id"]) elif conf == FROM_REQUESTED_REVIEWERS: if review["user"]["login"] in requested_reviewers_login: to_dismiss.add(review["id"]) elif isinstance(conf, list): if review["user"]["login"] in conf: to_dismiss.add(review["id"]) if not to_dismiss: return check_api.Result( check_api.Conclusion.SUCCESS, "Nothing to dismiss", "" ) errors = set() for review_id in to_dismiss: try: await ctxt.client.put( f"{ctxt.base_url}/pulls/{ctxt.pull['number']}/reviews/{review_id}/dismissals", json={"message": message}, ) except http.HTTPClientSideError as e: # pragma: no cover errors.add(f"GitHub error: [{e.status_code}] `{e.message}`") if errors: return check_api.Result( check_api.Conclusion.PENDING, "Unable to dismiss review", "\n".join(errors), ) else: await signals.send(ctxt, "action.dismiss_reviews") return check_api.Result( check_api.Conclusion.SUCCESS, "Review dismissed", "" )
def merge_report(self, ctxt: context.Context) -> typing.Optional[check_api.Result]: if ctxt.pull["draft"]: conclusion = check_api.Conclusion.PENDING title = "Draft flag needs to be removed" summary = "" elif ctxt.pull["merged"]: if ctxt.pull["merged_by"] is None: mode = "somehow" elif ctxt.pull["merged_by"]["login"] in [ "mergify[bot]", "mergify-test[bot]", ]: mode = "automatically" else: mode = "manually" conclusion = check_api.Conclusion.SUCCESS title = "The pull request has been merged %s" % mode summary = "The pull request has been merged %s at *%s*" % ( mode, ctxt.pull["merge_commit_sha"], ) elif ctxt.pull["state"] == "closed": conclusion = check_api.Conclusion.CANCELLED title = "The pull request has been closed manually" summary = "" # NOTE(sileht): Take care of all branch protection state elif ctxt.pull["mergeable_state"] == "dirty": conclusion = check_api.Conclusion.CANCELLED title = "Merge conflict needs to be solved" summary = "" elif ctxt.pull["mergeable_state"] == "unknown": conclusion = check_api.Conclusion.FAILURE title = "Pull request state reported as `unknown` by GitHub" summary = "" # FIXME(sileht): We disable this check as github wrongly report # mergeable_state == blocked sometimes. The workaround is to try to merge # it and if that fail we checks for blocking state. # elif ctxt.pull["mergeable_state"] == "blocked": # conclusion = "failure" # title = "Branch protection settings are blocking automatic merging" # summary = "" elif ( ctxt.pull["mergeable_state"] == "behind" and self.config["strict"] is StrictMergeParameter.false ): # Strict mode has been enabled in branch protection but not in # mergify conclusion = check_api.Conclusion.FAILURE title = "Branch protection setting 'strict' conflicts with Mergify configuration" summary = "" elif ctxt.github_workflow_changed(): conclusion = check_api.Conclusion.ACTION_REQUIRED title = "Pull request must be merged manually." summary = """GitHub App like Mergify are not allowed to merge pull request where `.github/workflows` is changed. <br /> This pull request must be merged manually.""" # NOTE(sileht): remaining state "behind, clean, unstable, has_hooks # are OK for us else: return None return check_api.Result(conclusion, title, summary)
async def run( ctxt: context.Context, sources: typing.List[context.T_PayloadEventSource], ) -> None: LOG.debug("engine get context") ctxt.log.debug("engine start processing context") issue_comment_sources: typing.List[T_PayloadEventIssueCommentSource] = [] for source in sources: if source["event_type"] == "issue_comment": issue_comment_sources.append( typing.cast(T_PayloadEventIssueCommentSource, source)) else: ctxt.sources.append(source) ctxt.log.debug("engine run pending commands") await commands_runner.run_pending_commands_tasks(ctxt) if issue_comment_sources: ctxt.log.debug("engine handle commands") for ic_source in issue_comment_sources: await commands_runner.handle( ctxt, ic_source["data"]["comment"]["body"], ic_source["data"]["comment"]["user"], ) if not ctxt.sources: return if ctxt.client.auth.permissions_need_to_be_updated: await ctxt.set_summary_check( check_api.Result( check_api.Conclusion.FAILURE, title="Required GitHub permissions are missing.", summary="You can accept them at https://dashboard.mergify.io/", )) return config_file = await ctxt.repository.get_mergify_config_file() ctxt.log.debug("engine check configuration change") if await _check_configuration_changes(ctxt, config_file): ctxt.log.info("Configuration changed, ignoring") return ctxt.log.debug("engine get configuration") if config_file is None: ctxt.log.info("No need to proceed queue (.mergify.yml is missing)") return # BRANCH CONFIGURATION CHECKING try: mergify_config = rules.get_mergify_config(config_file) except rules.InvalidRules as e: # pragma: no cover ctxt.log.info( "The Mergify configuration is invalid", summary=str(e), annotations=e.get_annotations(e.filename), ) # Not configured, post status check with the error message for s in ctxt.sources: if s["event_type"] == "pull_request": event = typing.cast(github_types.GitHubEventPullRequest, s["data"]) if event["action"] in ("opened", "synchronize"): await ctxt.set_summary_check( check_api.Result( check_api.Conclusion.FAILURE, title="The Mergify configuration is invalid", summary=str(e), annotations=e.get_annotations(e.filename), )) break return # Add global and mandatory rules mergify_config["pull_request_rules"].rules.extend( DEFAULT_PULL_REQUEST_RULES.rules) if ctxt.pull["base"]["repo"][ "private"] and not ctxt.subscription.has_feature( subscription.Features.PRIVATE_REPOSITORY): ctxt.log.info("mergify disabled: private repository") await ctxt.set_summary_check( check_api.Result( check_api.Conclusion.FAILURE, title="Mergify is disabled", summary=ctxt.subscription.reason, )) return await _ensure_summary_on_head_sha(ctxt) # NOTE(jd): that's fine for now, but I wonder if we wouldn't need a higher abstraction # to have such things run properly. Like hooks based on events that you could # register. It feels hackish otherwise. for s in ctxt.sources: if s["event_type"] == "pull_request": event = typing.cast(github_types.GitHubEventPullRequest, s["data"]) if event["action"] == "closed": await ctxt.clear_cached_last_summary_head_sha() break ctxt.log.debug("engine handle actions") if ctxt.is_merge_queue_pr(): await queue_runner.handle(mergify_config["queue_rules"], ctxt) else: await actions_runner.handle(mergify_config["pull_request_rules"], ctxt)
async def run(self, ctxt: context.Context, rule: rules.EvaluatedRule) -> check_api.Result: labels_added: typing.Set[str] = set() labels_removed: typing.Set[str] = set() pull_labels = {label["name"].lower() for label in ctxt.pull["labels"]} if self.config["add"]: for label in self.config["add"]: await ctxt.repository.ensure_label_exists(label) missing_labels = {label.lower() for label in self.config["add"]} - pull_labels if missing_labels: await ctxt.client.post( f"{ctxt.base_url}/issues/{ctxt.pull['number']}/labels", json={"labels": list(missing_labels)}, ) labels_added = missing_labels labels_by_name = { _l["name"].lower(): _l for _l in await ctxt.repository.get_labels() } ctxt.pull["labels"].extend([ labels_by_name[label_name] for label_name in missing_labels ]) if self.config["remove_all"]: if ctxt.pull["labels"]: await ctxt.client.delete( f"{ctxt.base_url}/issues/{ctxt.pull['number']}/labels") labels_removed = { label["name"] for label in ctxt.pull["labels"] } ctxt.pull["labels"] = [] elif self.config["remove"]: pull_labels = { label["name"].lower() for label in ctxt.pull["labels"] } for label in self.config["remove"]: if label.lower() in pull_labels: label_escaped = parse.quote(label, safe="") try: await ctxt.client.delete( f"{ctxt.base_url}/issues/{ctxt.pull['number']}/labels/{label_escaped}" ) except http.HTTPClientSideError as e: ctxt.log.warning( "fail to delete label", label=label, status_code=e.status_code, error_message=e.message, ) continue ctxt.pull["labels"] = [ _l for _l in ctxt.pull["labels"] if _l["name"].lower() != label.lower() ] labels_removed.add(label) if labels_added or labels_removed: await signals.send( ctxt.repository, ctxt.pull["number"], "action.label", signals.EventLabelMetadata({ "added": sorted(labels_added), "removed": sorted(labels_removed) }), ) return check_api.Result(check_api.Conclusion.SUCCESS, "Labels added/removed", "") else: return check_api.Result(check_api.Conclusion.SUCCESS, "No label to add or remove", "")