github-overlord 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: github-overlord
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Automatically merge Dependabot PRs in public repositories
|
|
5
|
+
Keywords: github,dependabot,automation
|
|
6
|
+
Author: Michael Bianco
|
|
7
|
+
Author-email: mike@mikebian.co
|
|
8
|
+
Requires-Python: >=3.12,<4.0
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Dist: click (>=8.1.7,<9.0.0)
|
|
12
|
+
Requires-Dist: funcy-pipe (>=0.11.0,<0.12.0)
|
|
13
|
+
Requires-Dist: pygithub (>=2.3.0,<3.0.0)
|
|
14
|
+
Requires-Dist: python-decouple-typed (>=3.11.0,<4.0.0)
|
|
15
|
+
Requires-Dist: structlog (>=24.2.0,<25.0.0)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
[](https://github.com/iloveitaly/github-overlord/releases) [](https://pepy.tech/project/github-overlord) [](https://pypi.org/project/github-overlord)  [](https://opensource.org/licenses/MIT)
|
|
19
|
+
|
|
20
|
+
# GitHub Overlord
|
|
21
|
+
|
|
22
|
+
GitHub Overlord is a Python script that automatically merges Dependabot PRs in public repositories that have passed CI checks.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```shell
|
|
28
|
+
pip install github-overlord
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```shell
|
|
34
|
+
Usage: github-overlord [OPTIONS]
|
|
35
|
+
|
|
36
|
+
Automatically merge dependabot PRs in public repos that have passed CI
|
|
37
|
+
checks
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--token TEXT GitHub token, can also be set via GITHUB_TOKEN
|
|
41
|
+
--dry-run Run script without merging PRs
|
|
42
|
+
--repo TEXT Only process a single repository
|
|
43
|
+
--help Show this message and exit.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Docker Cron
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[](https://github.com/iloveitaly/github-overlord/releases) [](https://pepy.tech/project/github-overlord) [](https://pypi.org/project/github-overlord)  [](https://opensource.org/licenses/MIT)
|
|
2
|
+
|
|
3
|
+
# GitHub Overlord
|
|
4
|
+
|
|
5
|
+
GitHub Overlord is a Python script that automatically merges Dependabot PRs in public repositories that have passed CI checks.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```shell
|
|
11
|
+
pip install github-overlord
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```shell
|
|
17
|
+
Usage: github-overlord [OPTIONS]
|
|
18
|
+
|
|
19
|
+
Automatically merge dependabot PRs in public repos that have passed CI
|
|
20
|
+
checks
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--token TEXT GitHub token, can also be set via GITHUB_TOKEN
|
|
24
|
+
--dry-run Run script without merging PRs
|
|
25
|
+
--repo TEXT Only process a single repository
|
|
26
|
+
--help Show this message and exit.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Docker Cron
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import funcy_pipe as fp
|
|
7
|
+
from github import Github
|
|
8
|
+
from github.GithubObject import NotSet
|
|
9
|
+
from github.PullRequest import PullRequest
|
|
10
|
+
|
|
11
|
+
from github_overlord.utils import log
|
|
12
|
+
|
|
13
|
+
AUTOMATIC_MERGE_MESSAGE = "Automatically merged with GitHub overlord"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def merge_pr(pr, dry_run):
|
|
17
|
+
if dry_run:
|
|
18
|
+
log.info("would merge PR", pr=pr.html_url)
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
pr.create_issue_comment(AUTOMATIC_MERGE_MESSAGE)
|
|
22
|
+
pr.merge(merge_method="squash")
|
|
23
|
+
|
|
24
|
+
log.info("merged PR", pr=pr.html_url)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_async_status(object, key):
|
|
28
|
+
"""
|
|
29
|
+
https://github.com/PyGithub/PyGithub/issues/1979
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
count_limit = 10
|
|
33
|
+
|
|
34
|
+
while getattr(object, key) is None:
|
|
35
|
+
time.sleep(1)
|
|
36
|
+
|
|
37
|
+
setattr(object, f"_{key}", NotSet)
|
|
38
|
+
object._CompletableGithubObject__completed = False
|
|
39
|
+
|
|
40
|
+
count_limit -= 1
|
|
41
|
+
if count_limit == 0:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_eligible_for_merge(pr: PullRequest):
|
|
46
|
+
resolve_async_status(pr, "mergeable")
|
|
47
|
+
|
|
48
|
+
if not pr.mergeable:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
if pr.user.login != "dependabot[bot]":
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
# pr_title = pr.title.lower()
|
|
55
|
+
# if "patch" not in pr_title and "github actions" not in pr_title:
|
|
56
|
+
# return False
|
|
57
|
+
|
|
58
|
+
last_commit = pr.get_commits().reversed[0]
|
|
59
|
+
combined_status = last_commit.get_combined_status()
|
|
60
|
+
status = combined_status.state
|
|
61
|
+
|
|
62
|
+
# status is different than CI runs!
|
|
63
|
+
if len(combined_status.statuses) > 0 and status != "success":
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
all_checks_successful = (
|
|
67
|
+
last_commit.get_check_runs() | fp.pluck_attr("conclusion") | fp.all("success")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if not all_checks_successful:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def process_repo(repo, dry_run):
|
|
77
|
+
log.debug("checking repository", repo=repo.full_name)
|
|
78
|
+
|
|
79
|
+
if repo.fork:
|
|
80
|
+
log.debug("skipping forked repo")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
pulls = repo.get_pulls(state="open")
|
|
84
|
+
|
|
85
|
+
if pulls.totalCount == 0:
|
|
86
|
+
log.debug("no open prs, skipping")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
merged_pr_count = 0
|
|
90
|
+
|
|
91
|
+
for pr in pulls:
|
|
92
|
+
if is_eligible_for_merge(pr):
|
|
93
|
+
merge_pr(pr, dry_run)
|
|
94
|
+
|
|
95
|
+
merged_pr_count += 1
|
|
96
|
+
else:
|
|
97
|
+
log.debug("skipping PR", url=pr.html_url)
|
|
98
|
+
|
|
99
|
+
log.info("merged prs", count=merged_pr_count)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(token, dry_run, repo):
|
|
103
|
+
assert token, "GitHub token is required"
|
|
104
|
+
|
|
105
|
+
g = Github(token)
|
|
106
|
+
user = g.get_user()
|
|
107
|
+
|
|
108
|
+
if repo:
|
|
109
|
+
process_repo(g.get_repo(repo), dry_run)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# if not, process everything!
|
|
113
|
+
user.get_repos(type="public") | fp.filter(
|
|
114
|
+
lambda repo: repo.owner.login == user.login
|
|
115
|
+
) | fp.map(fp.rpartial(process_repo, dry_run)) | fp.to_list()
|
|
116
|
+
|
|
117
|
+
log.info("dependabot pr check complete")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@click.command()
|
|
121
|
+
@click.option(
|
|
122
|
+
"--token",
|
|
123
|
+
help="GitHub token, can also be set via GITHUB_TOKEN",
|
|
124
|
+
default=os.getenv("GITHUB_TOKEN"),
|
|
125
|
+
)
|
|
126
|
+
@click.option("--dry-run", is_flag=True, help="Run script without merging PRs")
|
|
127
|
+
@click.option("--repo", help="Only process a single repository")
|
|
128
|
+
def cli(token, dry_run, repo):
|
|
129
|
+
"""
|
|
130
|
+
Automatically merge dependabot PRs in public repos that have passed CI checks
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
if repo:
|
|
134
|
+
# convert 'https://github.com/iloveitaly/todoist-digest/pulls' to 'iloveitaly/todoist-digest'
|
|
135
|
+
if "github.com" in repo:
|
|
136
|
+
match = re.search(r"github\.com/([^/]+)/([^/]+)", repo)
|
|
137
|
+
|
|
138
|
+
if match:
|
|
139
|
+
repo = f"{match.group(1)}/{match.group(2)}"
|
|
140
|
+
|
|
141
|
+
main(token, dry_run, repo)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
cli()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file should be imported first in any application entrypoint.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import typing as t
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from decouple import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def configure_logger():
|
|
14
|
+
logger_factory = structlog.PrintLoggerFactory()
|
|
15
|
+
|
|
16
|
+
# allow user to specify a log in case they want to do something meaningful with the stdout
|
|
17
|
+
if python_log_path := config("PYTHON_LOG_PATH", default=None):
|
|
18
|
+
python_log = open(
|
|
19
|
+
python_log_path, "a", encoding="utf-8"
|
|
20
|
+
) # pylint: disable=consider-using-with
|
|
21
|
+
logger_factory = structlog.PrintLoggerFactory(file=python_log)
|
|
22
|
+
|
|
23
|
+
log_level = t.cast(str, config("LOG_LEVEL", default="INFO", cast=str))
|
|
24
|
+
level = getattr(logging, log_level.upper())
|
|
25
|
+
|
|
26
|
+
# TODO logging.root.manager.loggerDict
|
|
27
|
+
# we need this option to be set for other non-structlog loggers
|
|
28
|
+
logging.basicConfig(level=level)
|
|
29
|
+
|
|
30
|
+
# TODO look into further customized format
|
|
31
|
+
# https://cs.github.com/GeoscienceAustralia/digitalearthau/blob/4cf486eb2a93d7de23f86ce6de0c3af549fe42a9/digitalearthau/uiutil.py#L45
|
|
32
|
+
|
|
33
|
+
structlog.configure(
|
|
34
|
+
context_class=dict,
|
|
35
|
+
wrapper_class=structlog.make_filtering_bound_logger(level),
|
|
36
|
+
logger_factory=logger_factory,
|
|
37
|
+
cache_logger_on_first_use=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# must type manually, unfortunately :/
|
|
42
|
+
# https://www.structlog.org/en/21.3.0/types.html
|
|
43
|
+
log: structlog.stdlib.BoundLogger
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def setup():
|
|
47
|
+
if hasattr(setup, "complete") and setup.complete:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
global root, logger, log
|
|
51
|
+
|
|
52
|
+
root = Path(__file__).parent.parent
|
|
53
|
+
|
|
54
|
+
log = structlog.get_logger()
|
|
55
|
+
|
|
56
|
+
# context manager to auto-clear context
|
|
57
|
+
log.context = structlog.contextvars.bound_contextvars # type: ignore
|
|
58
|
+
# set thread-local context
|
|
59
|
+
log.local = structlog.contextvars.bind_contextvars # type: ignore
|
|
60
|
+
# clear thread-local context
|
|
61
|
+
log.clear = structlog.contextvars.clear_contextvars # type: ignore
|
|
62
|
+
|
|
63
|
+
logger = log
|
|
64
|
+
|
|
65
|
+
configure_logger()
|
|
66
|
+
|
|
67
|
+
log.info("application setup")
|
|
68
|
+
|
|
69
|
+
# local state in a method is strange, but it works :/
|
|
70
|
+
setup.complete = True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# side effects are bad, but it's fun to do bad things
|
|
74
|
+
setup()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "github-overlord"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Automatically merge Dependabot PRs in public repositories"
|
|
5
|
+
authors = ["Michael Bianco <mike@mikebian.co>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
keywords = ["github", "dependabot", "automation"]
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
python = "^3.12"
|
|
11
|
+
pygithub = "^2.3.0"
|
|
12
|
+
click = "^8.1.7"
|
|
13
|
+
python-decouple-typed = "^3.11.0"
|
|
14
|
+
funcy-pipe = "^0.11.0"
|
|
15
|
+
structlog = "^24.2.0"
|
|
16
|
+
|
|
17
|
+
[tool.poetry.scripts]
|
|
18
|
+
github-overlord = "github_overlord:cli"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
[tool.isort]
|
|
22
|
+
profile = "black"
|
|
23
|
+
# helpful when copy/pasting GPT code
|
|
24
|
+
float_to_top = true
|
|
25
|
+
|
|
26
|
+
[tool.poetry.group.dev.dependencies]
|
|
27
|
+
isort = "^5.13.2"
|
|
28
|
+
black = "^24.4.2"
|
|
29
|
+
pyright = "^1.1.366"
|
|
30
|
+
pylint = "^3.2.3"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["poetry-core"]
|
|
34
|
+
build-backend = "poetry.core.masonry.api"
|