async def get_summary_check_result( ctxt: context.Context, pull_request_rules: rules.PullRequestRules, match: rules.RulesEvaluator, summary_check: typing.Optional[github_types.CachedGitHubCheckRun], conclusions: typing.Dict[str, check_api.Conclusion], previous_conclusions: typing.Dict[str, check_api.Conclusion], ) -> typing.Optional[check_api.Result]: summary_title, summary = await gen_summary(ctxt, pull_request_rules, match) serialized_conclusions = serialize_conclusions(conclusions) summary_for_logging = summary + serialized_conclusions summary += constants.MERGIFY_PULL_REQUEST_DOC summary += serialized_conclusions summary_changed = ( not summary_check or summary_check["output"]["title"] != summary_title or summary_check["output"]["summary"] != summary # Even the check-run content didn't change we must report the same content to # update the check_suite or ctxt.user_refresh_requested() or ctxt.admin_refresh_requested() ) if summary_changed: ctxt.log.info( "summary changed", summary={ "title": summary_title, "name": constants.SUMMARY_NAME, "summary": summary_for_logging, }, conclusions=conclusions, previous_conclusions=previous_conclusions, ) return check_api.Result( check_api.Conclusion.SUCCESS, title=summary_title, summary=summary ) else: ctxt.log.info( "summary unchanged", summary={ "title": summary_title, "name": constants.SUMMARY_NAME, "summary": summary_for_logging, }, conclusions=conclusions, previous_conclusions=previous_conclusions, ) # NOTE(sileht): Here we run the engine, but nothing change so we didn't # update GitHub. In pratice, only the started_at and the ended_at is # not up2date, we don't really care, as no action has ran return None
async def post_summary( ctxt: context.Context, pull_request_rules: rules.PullRequestRules, match: rules.RulesEvaluator, summary_check: typing.Optional[github_types.GitHubCheckRun], conclusions: typing.Dict[str, check_api.Conclusion], previous_conclusions: typing.Dict[str, check_api.Conclusion], ) -> None: summary_title, summary = await gen_summary(ctxt, pull_request_rules, match) summary += constants.MERGIFY_PULL_REQUEST_DOC summary += serialize_conclusions(conclusions) summary_changed = ( not summary_check or summary_check["output"]["title"] != summary_title or summary_check["output"]["summary"] != summary # Even the check-run content didn't change we must report the same content to # update the check_suite or ctxt.user_refresh_requested() or ctxt.admin_refresh_requested()) if summary_changed: ctxt.log.info( "summary changed", summary={ "title": summary_title, "name": ctxt.SUMMARY_NAME, "summary": summary, }, sources=_filterred_sources_for_logging(ctxt.sources), conclusions=conclusions, previous_conclusions=previous_conclusions, ) await ctxt.set_summary_check( check_api.Result(check_api.Conclusion.SUCCESS, title=summary_title, summary=summary)) else: ctxt.log.info( "summary unchanged", summary={ "title": summary_title, "name": ctxt.SUMMARY_NAME, "summary": summary, }, sources=_filterred_sources_for_logging(ctxt.sources), conclusions=conclusions, previous_conclusions=previous_conclusions, )
async def run(self, ctxt: context.Context, rule: "rules.EvaluatedRule") -> check_api.Result: if not ctxt.subscription.has_feature( subscription.Features.QUEUE_ACTION): return check_api.Result( check_api.Conclusion.ACTION_REQUIRED, "Queue action is disabled", ctxt.subscription.missing_feature_reason( ctxt.pull["base"]["repo"]["owner"]["login"]), ) q = await merge_train.Train.from_context(ctxt) car = q.get_car(ctxt) if car and car.state == "updated": # NOTE(sileht): This car doesn't have tmp pull, so we have the # MERGE_QUEUE_SUMMARY and train reset here need_reset = ctxt.have_been_synchronized() or await ctxt.is_behind if need_reset: status = check_api.Conclusion.PENDING ctxt.log.info("train will be reset") await q.reset() else: queue_rule_evaluated = await self.queue_rule.get_pull_request_rule( ctxt) status = await merge_train.get_queue_rule_checks_status( ctxt, queue_rule_evaluated) await car.update_summaries(status, will_be_reset=need_reset) 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_queue 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, ]: 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", "", ), ) return await super().run(ctxt, rule)
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 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 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