Skip to content

Commit

Permalink
add(bot): auto merge by dependabot version (#607)
Browse files Browse the repository at this point in the history
Add configuration option to auto merge PRs opened by Dependabot (and others) based on the upgrade type (major, minor, patch).

Snyk creates batch PRs, but Dependabot doesn't. I think for a first pass we can just support single dependency pull requests.

```toml
[merge]
automerge_label = "ship it!"

[merge.automerge_dependencies]
versions = ["minor","patch"]
usernames = ["dependabot"]

[update]
ignored_usernames = ["dependabot"]
```

related #606
  • Loading branch information
chdsbd authored Jan 29, 2021
1 parent 1238e93 commit b1893ee
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 1 deletion.
7 changes: 7 additions & 0 deletions bot/kodiak/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import toml
from pydantic import BaseModel, ValidationError, validator
from typing_extensions import Literal


class MergeMethod(str, Enum):
Expand Down Expand Up @@ -52,9 +53,15 @@ class MergeMessage(BaseModel):
DEFAULT_TITLE_REGEX = "^WIP:.*"


class AutomergeDependencies(BaseModel):
versions: List[Literal["major", "minor", "patch"]] = []
usernames: List[str] = []


class Merge(BaseModel):
# label or labels to enable merging of pull request.
automerge_label: Union[str, List[str]] = "automerge"
automerge_dependencies: AutomergeDependencies = AutomergeDependencies()
# if disabled, kodiak won't require a label to queue a PR for merge
require_automerge_label: bool = True
# regex to match against title and block merging. Set to empty string to
Expand Down
83 changes: 83 additions & 0 deletions bot/kodiak/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import re
from typing import List, Optional, Sequence, Tuple, TypeVar

from typing_extensions import Literal

title_regex = re.compile(r"from (?P<old_version>\S+) to (?P<new_version>\S+)")


def _extract_versions(x: str) -> Optional[Tuple[str, str]]:
"""
Find old and new version from PR title
Example:
title: "Bump jackson-databind from 2.9.10.1 to 2.10.0.pr1 in /LiveIngest/LiveEventWithDVR"
result: "2.9.10.1", "2.10.0.pr1"
"""
match = title_regex.search(x)
if match is None:
return None
group = match.groupdict()
if "old_version" not in group or "new_version" not in group:
return None
return group["old_version"], group["new_version"]


# regex to split version string. Versions aren't necessarily semver.
#
# from dependabot: https://github.com/dependabot/dependabot-core/blob/998de3be7811956354aea077ecb180831e24012c/common/lib/dependabot/pull_request_creator/labeler.rb#L95-L115
version_regex = re.compile(r"[.+]")


def _parse_version_simple(x: str) -> List[str]:
"""
Split version string into pieces.
"""
return version_regex.split(x)


T = TypeVar("T")


def _get_or_none(arr: Sequence[T], index: int) -> Optional[T]:
try:
return arr[index]
except IndexError:
return None


def _compare_versions(
old_version: str, new_version: str
) -> Optional[Literal["major", "minor", "patch"]]:
"""
Determine patch, like Dependabot.
https://github.com/dependabot/dependabot-core/blob/998de3be7811956354aea077ecb180831e24012c/common/lib/dependabot/pull_request_creator/labeler.rb#L92-L114
"""
old_version_parts = _parse_version_simple(old_version)
new_version_parts = _parse_version_simple(new_version)

for part in new_version_parts[:3] + old_version_parts[:3]:
try:
int(part)
except ValueError:
return None

if _get_or_none(new_version_parts, 0) != _get_or_none(old_version_parts, 0):
return "major"
if _get_or_none(new_version_parts, 1) != _get_or_none(old_version_parts, 1):
return "minor"
return "patch"


def dep_version_from_title(x: str) -> Optional[Literal["major", "minor", "patch"]]:
"""
Try to determine the semver upgrade type from string.
For example, 'Bump lodash from 4.17.15 to 4.17.19', would be "patch", since the "patch" field is upgraded from 15 to 19.
"""
res = _extract_versions(x)
if res is None:
return None
old_version, new_version = res
return _compare_versions(old_version, new_version)
13 changes: 12 additions & 1 deletion bot/kodiak/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
MergeMethod,
MergeTitleStyle,
)
from kodiak.dependencies import dep_version_from_title
from kodiak.errors import (
GitHubApiInternalServerError,
PollForever,
Expand Down Expand Up @@ -611,6 +612,12 @@ async def set_status(msg: str, markdown_content: Optional[str] = None) -> None:
)
has_automerge_label = len(pull_request_automerge_labels) > 0

should_dependency_automerge = (
pull_request.author.login in config.merge.automerge_dependencies.usernames
and dep_version_from_title(pull_request.title)
in config.merge.automerge_dependencies.versions
)

# we should trigger mergeability checks whenever we encounter UNKNOWN.
#
# I don't foresee conflicts with checking configuration errors,
Expand Down Expand Up @@ -697,7 +704,11 @@ async def set_status(msg: str, markdown_content: Optional[str] = None) -> None:
await api.update_branch()
return

if config.merge.require_automerge_label and not has_automerge_label:
if (
config.merge.require_automerge_label
and not has_automerge_label
and not should_dependency_automerge
):
await block_merge(
api,
pull_request,
Expand Down
52 changes: 52 additions & 0 deletions bot/kodiak/test/fixtures/config/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"title": "Merge",
"default": {
"automerge_label": "automerge",
"automerge_dependencies": {
"versions": [],
"usernames": []
},
"require_automerge_label": true,
"blacklist_title_regex": ":::|||kodiak|||internal|||reserved|||:::",
"blocking_title_regex": ":::|||kodiak|||internal|||reserved|||:::",
Expand Down Expand Up @@ -84,6 +88,42 @@
"version"
],
"definitions": {
"AutomergeDependencies": {
"title": "AutomergeDependencies",
"type": "object",
"properties": {
"versions": {
"title": "Versions",
"default": [],
"type": "array",
"items": {
"title": " Versions",
"anyOf": [
{
"const": "major",
"type": "string"
},
{
"const": "minor",
"type": "string"
},
{
"const": "patch",
"type": "string"
}
]
}
},
"usernames": {
"title": "Usernames",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"MergeMessage": {
"title": "MergeMessage",
"description": "https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button",
Expand Down Expand Up @@ -171,6 +211,18 @@
}
]
},
"automerge_dependencies": {
"title": "Automerge Dependencies",
"default": {
"versions": [],
"usernames": []
},
"allOf": [
{
"$ref": "#/definitions/AutomergeDependencies"
}
]
},
"require_automerge_label": {
"title": "Require Automerge Label",
"default": true,
Expand Down
53 changes: 53 additions & 0 deletions bot/kodiak/test_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from kodiak.dependencies import (
_compare_versions,
_extract_versions,
dep_version_from_title,
)


def test_extract_versions() -> None:
for title, version, upgrade in [
(
"Bump pip from 20.2.4 to 20.3 in /.github/workflows",
("20.2.4", "20.3"),
"minor",
),
("Bump lodash from 4.17.15 to 4.17.19", ("4.17.15", "4.17.19"), "patch"),
("Update tokio requirement from 0.2 to 0.3", ("0.2", "0.3"), "minor"),
(
"Bump jackson-databind from 2.9.10.1 to 2.10.0.pr1 in /LiveIngest/LiveEventWithDVR",
("2.9.10.1", "2.10.0.pr1"),
"minor",
),
(
"Bump commons-collections from 4.0 to 4.1 in /eosio-explorer/Quantum",
("4.0", "4.1"),
"minor",
),
(
"[Snyk] Security upgrade engine.io from 3.5.0 to 4.0.0",
("3.5.0", "4.0.0"),
"major",
),
("Bump lodash", None, None),
("Bump lodash to 4.17.19", None, None),
("Bump lodash from 4.17.15 to", None, None),
]:
assert _extract_versions(title) == version
assert dep_version_from_title(title) == upgrade


def test_compare_versions() -> None:
for old_version, new_version, change in [
("20.2.4", "20.3", "minor"),
("4.17.15", "4.17.19", "patch"),
("0.2", "0.3", "minor"),
("2.9.10.1", "2.10.0.pr1", "minor"),
("4.0", "4.1", "minor"),
("1.5", "2.0", "major"),
("feb", "may", None),
]:
assert (
_compare_versions(old_version=old_version, new_version=new_version)
== change
)
98 changes: 98 additions & 0 deletions bot/kodiak/tests/evaluation/test_automerge_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import pytest

from kodiak.test_evaluation import (
create_api,
create_config,
create_mergeable,
create_pull_request,
)


@pytest.mark.asyncio
async def test_merge_okay() -> None:
"""
Happy case.
The upgrade type (patch) is specified in "versions" and the PR author
"my-custom-dependabot" is specified in "usernames".
"""
mergeable = create_mergeable()
config = create_config()
config.merge.automerge_dependencies.versions = ["minor", "patch"]
config.merge.automerge_dependencies.usernames = ["my-custom-dependabot"]
pull_request = create_pull_request()
pull_request.labels = []
pull_request.author.login = "my-custom-dependabot"
pull_request.title = "Bump lodash from 4.17.15 to 4.17.19"
api = create_api()
await mergeable(api=api, pull_request=pull_request, config=config)
assert api.set_status.call_count == 1
assert "enqueued" in api.set_status.calls[0]["msg"]
assert api.queue_for_merge.call_count == 1
assert api.dequeue.call_count == 0


@pytest.mark.asyncio
async def test_merge_mismatch_username() -> None:
"""
We should only merge the pull request if the userrname is specified within
"usernames" and the version type is in the "versions" field.
"""
mergeable = create_mergeable()
config = create_config()
config.merge.automerge_dependencies.versions = ["minor", "patch"]
config.merge.automerge_dependencies.usernames = ["dependabot"]
pull_request = create_pull_request()
pull_request.labels = []
pull_request.author.login = "my-custom-dependabot"
pull_request.title = "Bump lodash from 4.17.15 to 4.17.19"
api = create_api()
await mergeable(api=api, pull_request=pull_request, config=config)
assert api.queue_for_merge.call_count == 0
assert api.dequeue.call_count == 1


@pytest.mark.asyncio
async def test_merge_no_version_found() -> None:
"""
If we can't find a version from the PR title, we shouldn't merge.
Packages don't necessarily use semver.
"""
mergeable = create_mergeable()
config = create_config()
config.merge.automerge_dependencies.versions = ["minor", "patch"]
config.merge.automerge_dependencies.usernames = ["my-custom-dependabot"]
pull_request = create_pull_request()
pull_request.labels = []
pull_request.author.login = "my-custom-dependabot"

for title in ("Bump lodash from 4.17.15 to", "Bump lodash from griffin to phoenix"):
pull_request.title = title
api = create_api()
await mergeable(api=api, pull_request=pull_request, config=config)
assert api.queue_for_merge.call_count == 0
assert api.dequeue.call_count == 1


@pytest.mark.asyncio
async def test_merge_disallowed_version() -> None:
"""
We should only auto merge if the upgrade type is specified in "versions".
So if a PR is a major upgrade, we should only auto merge if "major" is in
the "versions" configuration.
"""
mergeable = create_mergeable()
config = create_config()
config.merge.automerge_dependencies.versions = ["minor", "patch"]
config.merge.automerge_dependencies.usernames = ["my-custom-dependabot"]
pull_request = create_pull_request()
pull_request.labels = []
pull_request.author.login = "my-custom-dependabot"
pull_request.title = "Bump lodash from 4.17.15 to 5.0.1"
api = create_api()
await mergeable(api=api, pull_request=pull_request, config=config)
assert api.queue_for_merge.call_count == 0
assert api.dequeue.call_count == 1

0 comments on commit b1893ee

Please sign in to comment.