Esempio n. 1
0
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
Esempio n. 2
0
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,
        )
Esempio n. 3
0
    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)
Esempio n. 4
0
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
Esempio n. 5
0
    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
Esempio n. 6
0
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