Skip to content

Commit 4f8ed1b

Browse files
committed
create auto_merge package
1 parent 4886205 commit 4f8ed1b

File tree

8 files changed

+1028
-0
lines changed

8 files changed

+1028
-0
lines changed

airbyte-ci/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ The installation instructions for the `airbyte-ci` CLI tool cal be found here
1515
| [`connectors_qa`](connectors/connectors_qa/) | A tool to verify connectors have sounds assets and metadata. |
1616
| [`metadata_service`](connectors/metadata_service/) | Tools to generate connector metadata and registry. |
1717
| [`pipelines`](connectors/pipelines/) | Airbyte CI pipelines, including formatting, linting, building, testing connectors, etc. Connector acceptance tests live here. |
18+
| [`auto_merge`](connectors/auto_merge/) | A tool to automatically merge connector pull requests. |
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# `auto_merge`
2+
3+
This python package is made to merge pull requests automatically on the Airbyte Repo. It is used in the [following workflow](TBD).
4+
5+
A pull request is currently considered as auto-mergeable if:
6+
- It has the `auto-merge` label
7+
- It is only touching files in connector related directories
8+
- All the required checks have passed
9+
10+
11+
## Install and usage
12+
### Get a Github token
13+
You need to create a Github token with the following permissions:
14+
* Read access to the repository to list open pull requests and their statuses
15+
* Write access to the repository to merge pull requests
16+
17+
### Local install and run
18+
```
19+
poetry install
20+
export GITHUB_TOKEN=<your_github_token>
21+
# By default no merge will be done, you need to set the AUTO_MERGE_PRODUCTION environment variable to true to actually merge the PRs
22+
poetry run auto-merge
23+
```
24+
25+
### In CI
26+
```
27+
export GITHUB_TOKEN=<your_github_token>
28+
export AUTO_MERGE_PRODUCTION=true
29+
poetry install
30+
poetry run auto-merge
31+
```
32+
33+
The execution will set the `GITHUB_STEP_SUMMARY` env var with a markdown summary of the PRs that have been merged.

airbyte-ci/connectors/auto_merge/poetry.lock

+754
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[tool.poetry]
2+
name = "auto-merge"
3+
version = "0.1.0"
4+
description = ""
5+
authors = ["Airbyte <[email protected]>"]
6+
readme = "README.md"
7+
packages = [
8+
{ include = "auto_merge", from = "src" },
9+
]
10+
11+
[tool.poetry.dependencies]
12+
python = "^3.10"
13+
pygithub = "^2.3.0"
14+
anyio = "^4.3.0"
15+
16+
17+
[tool.poetry.group.dev.dependencies]
18+
mypy = "^1.10.0"
19+
ruff = "^0.4.3"
20+
pytest = "^8.2.0"
21+
pyinstrument = "^4.6.2"
22+
23+
[tool.ruff.lint]
24+
select = [
25+
"I" # isort
26+
]
27+
28+
[tool.poetry.scripts]
29+
auto-merge = "auto_merge.main:auto_merge"
30+
31+
[build-system]
32+
requires = ["poetry-core"]
33+
build-backend = "poetry.core.masonry.api"
34+
35+
[tool.poe.tasks]
36+
test = "pytest tests"
37+
type_check = "mypy src --disallow-untyped-defs"
38+
lint = "ruff check src"
39+
40+
[tool.airbyte_ci]
41+
optional_poetry_groups = ["dev"]
42+
poe_tasks = ["type_check", "lint",]

airbyte-ci/connectors/auto_merge/src/auto_merge/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
3+
from __future__ import annotations
4+
5+
AIRBYTE_REPO = "airbytehq/airbyte"
6+
AUTO_MERGE_LABEL = "auto-merge"
7+
BASE_BRANCH = "master"
8+
CONNECTOR_PATH_PREFIXES = {
9+
"airbyte-integrations/connectors/",
10+
"docs/integrations/sources",
11+
"docs/integrations/destinations",
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
3+
from __future__ import annotations
4+
5+
import os
6+
7+
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
8+
PRODUCTION = os.environ.get("AUTO_MERGE_PRODUCTION", "false").lower() == "true"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import os
7+
import time
8+
from collections.abc import Iterable, Iterator
9+
from contextlib import contextmanager
10+
from typing import TYPE_CHECKING
11+
12+
from github import Auth, Github
13+
14+
from .consts import AIRBYTE_REPO, AUTO_MERGE_LABEL, BASE_BRANCH, CONNECTOR_PATH_PREFIXES
15+
from .env import GITHUB_TOKEN, PRODUCTION
16+
17+
if TYPE_CHECKING:
18+
from github.Commit import Commit as GithubCommit
19+
from github.File import File as GithubFile
20+
from github.PullRequest import PullRequest
21+
from github.Repository import Repository as GithubRepo
22+
23+
logging.basicConfig()
24+
logging.getLogger().setLevel(logging.INFO)
25+
26+
27+
@contextmanager
28+
def github_client() -> Iterator[Github]:
29+
client = None
30+
try:
31+
client = Github(auth=Auth.Token(GITHUB_TOKEN), seconds_between_requests=0)
32+
yield client
33+
finally:
34+
if client:
35+
client.close()
36+
37+
38+
def check_if_modifies_connectors_only(modified_files: Iterable[GithubFile]) -> bool:
39+
"""Check if all modified files are in CONNECTOR_PATH_PREFIXES
40+
41+
Args:
42+
modified_files (Iterable[GithubFile]): All the modified files on a PR.
43+
44+
Returns:
45+
bool: True if all modified files are in CONNECTOR_PATH_PREFIXES, False otherwise
46+
"""
47+
for file in modified_files:
48+
if not any(file.filename.startswith(prefix) for prefix in CONNECTOR_PATH_PREFIXES):
49+
return False
50+
return True
51+
52+
53+
def check_if_head_commit_passes_all_required_checks(head_commit: GithubCommit, required_checks: set[str]) -> bool:
54+
"""Required checks can be a mix of status contexts and check runs. A head commit is considered to pass all required checks if it has successful statuses and check runs for all required checks.
55+
56+
Args:
57+
head_commit (GithubCommit): The head commit of the PR
58+
required_checks (set[str]): The set of required passing checks
59+
60+
Returns:
61+
bool: True if the head commit passes all required checks, False otherwise
62+
"""
63+
successful_status_contexts = [commit_status.context for commit_status in head_commit.get_statuses() if commit_status.state == "success"]
64+
successful_check_runs = [check_run.name for check_run in head_commit.get_check_runs() if check_run.conclusion == "success"]
65+
successful_contexts = set(successful_status_contexts + successful_check_runs)
66+
return required_checks.issubset(successful_contexts)
67+
68+
69+
def check_if_pr_is_auto_mergeable(head_commit: GithubCommit, pr: PullRequest, required_checks: set[str]) -> bool:
70+
"""A PR is considered auto-mergeable if:
71+
- it has the AUTO_MERGE_LABEL
72+
- it targets the BASE_BRANCH
73+
- it touches only files in CONNECTOR_PATH_PREFIXES
74+
- the head commit passes all required checks
75+
76+
Args:
77+
head_commit (GithubCommit): The head commit of the PR
78+
pr (PullRequest): The PR to check
79+
required_checks (set[str]): The set of required passing checks
80+
81+
Returns:
82+
bool: True if the PR is auto-mergeable, False otherwise
83+
"""
84+
has_auto_merge_label = any(label.name == AUTO_MERGE_LABEL for label in pr.labels)
85+
if not has_auto_merge_label:
86+
logging.info(f"PR {pr.number} does not have the {AUTO_MERGE_LABEL} label")
87+
return False
88+
targets_main_branch = pr.base.ref == BASE_BRANCH
89+
if not targets_main_branch:
90+
logging.info(f"PR {pr.number} does not target {BASE_BRANCH}")
91+
return False
92+
touches_connectors_only = check_if_modifies_connectors_only(pr.get_files())
93+
if not touches_connectors_only:
94+
logging.info(f"PR {pr.number} touches files outside connectors")
95+
return False
96+
passes_all_checks = check_if_head_commit_passes_all_required_checks(head_commit, required_checks)
97+
if not passes_all_checks:
98+
logging.info(f"PR {pr.number} does not pass all required checks")
99+
return False
100+
logging.info(f"PR {pr.number} is a candidate for auto-merge! 🎉")
101+
return True
102+
103+
104+
def process_pr(repo: GithubRepo, pr: PullRequest, required_passing_contexts: set[str], dry_run: bool) -> None | PullRequest:
105+
"""Process a PR to see if it is auto-mergeable and merge it if it is.
106+
107+
Args:
108+
repo (GithubRepo): The repository the PR is in
109+
pr (PullRequest): The PR to process
110+
required_passing_contexts (set[str]): The set of required passing checks
111+
dry_run (bool): Whether to actually merge the PR or not
112+
113+
Returns:
114+
None | PullRequest: The PR if it was merged, None otherwise
115+
"""
116+
logging.info(f"Processing PR {pr.number}")
117+
head_commit = repo.get_commit(sha=pr.head.sha)
118+
if check_if_pr_is_auto_mergeable(head_commit, pr, required_passing_contexts):
119+
if not dry_run:
120+
pr.merge()
121+
logging.info(f"PR {pr.number} auto-merged")
122+
return pr
123+
else:
124+
logging.info(f"PR {pr.number} is auto-mergeable but dry-run is enabled")
125+
126+
127+
def back_off_if_rate_limited(github_client: Github) -> None:
128+
"""Sleep if the rate limit is reached
129+
130+
Args:
131+
github_client (Github): The Github client to check the rate limit of
132+
"""
133+
remaining_requests, _ = github_client.rate_limiting
134+
if remaining_requests < 100:
135+
logging.warning(f"Rate limit almost reached. Remaining requests: {remaining_requests}")
136+
if remaining_requests == 0:
137+
logging.warning(f"Rate limited. Sleeping for {github_client.rate_limiting_resettime - time.time()} seconds")
138+
time.sleep(github_client.rate_limiting_resettime - time.time())
139+
140+
141+
def generate_job_summary_as_markdown(merged_prs: list[PullRequest]) -> str:
142+
"""Generate a markdown summary of the merged PRs
143+
144+
Args:
145+
merged_prs (list[PullRequest]): The PRs that were merged
146+
147+
Returns:
148+
str: The markdown summary
149+
"""
150+
summary_time = time.strftime("%Y-%m-%d %H:%M:%S")
151+
header = "# Auto-merged PRs"
152+
details = f"Summary generated at {summary_time}"
153+
if not merged_prs:
154+
return f"{header}\n\n{details}\n\n**No PRs were auto-merged**\n"
155+
merged_pr_list = "\n".join([f"- [#{pr.number} - {pr.title}]({pr.html_url})" for pr in merged_prs])
156+
return f"{header}\n\n{details}\n\n{merged_pr_list}\n"
157+
158+
159+
def auto_merge() -> None:
160+
"""Main function to auto-merge PRs that are candidates for auto-merge.
161+
If the AUTO_MERGE_PRODUCTION environment variable is not set to "true", this will be a dry run.
162+
"""
163+
dry_run = PRODUCTION is False
164+
with github_client() as gh_client:
165+
repo = gh_client.get_repo(AIRBYTE_REPO)
166+
main_branch = repo.get_branch(BASE_BRANCH)
167+
logging.info(f"Fetching required passing contexts for {BASE_BRANCH}")
168+
required_passing_contexts = set(main_branch.get_required_status_checks().contexts)
169+
candidate_issues = gh_client.search_issues(f"repo:{AIRBYTE_REPO} is:pr label:{AUTO_MERGE_LABEL} base:{BASE_BRANCH} state:open")
170+
prs = [issue.as_pull_request() for issue in candidate_issues]
171+
logging.info(f"Found {len(prs)} open PRs targeting {BASE_BRANCH} with the {AUTO_MERGE_LABEL} label")
172+
merged_prs = []
173+
for pr in prs:
174+
back_off_if_rate_limited(gh_client)
175+
if merged_pr := process_pr(repo, pr, required_passing_contexts, dry_run):
176+
merged_prs.append(merged_pr)
177+
if PRODUCTION:
178+
os.environ["GITHUB_STEP_SUMMARY"] = generate_job_summary_as_markdown(merged_prs)

0 commit comments

Comments
 (0)