Merge pull request 'clan-merge' (#31) from clan-merge into main
All checks were successful
build / test (push) Successful in 7s

This commit is contained in:
clan-bot 2023-07-26 12:34:49 +00:00
commit 6168cb7229
12 changed files with 470 additions and 7 deletions

View File

@ -39,6 +39,7 @@
./targets/flake-module.nix
./modules/flake-module.nix
./pkgs/flake-module.nix
./pkgs/clan-merge/flake-module.nix
];
perSystem = { pkgs, inputs', ... }: {
treefmt = {

View File

@ -0,0 +1,25 @@
{ config, self, pkgs, ... }: {
sops.secrets.merge-bot-gitea-token = { };
# service to for automatic merge bot
systemd.services.clan-merge = {
description = "Merge clan.lol PRs automatically";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = { GITEA_TOKEN_FILE = "%d/GITEA_TOKEN_FILE"; };
serviceConfig = {
LoadCredential = [ "GITEA_TOKEN_FILE:${config.sops.secrets.merge-bot-gitea-token.path}" ];
Restart = "on-failure";
DynamicUser = true;
};
script = ''
while sleep 10; do
${self.packages.${pkgs.system}.clan-merge}/bin/clan-merge \
--allowed-users DavHau lassulus mic92 \
--repos clan-infra clan-core clan-homepage \
--bot-name clan-bot
done
'';
};
}

View File

@ -1,11 +1,12 @@
{
imports = [
./homepage.nix
./gitea
./postfix.nix
./harmonia.nix
./dendrite.nix
./borgbackup.nix
./clan-merge.nix
./dendrite.nix
./gitea
./harmonia.nix
./homepage.nix
./postfix.nix
../zerotier
../zerotier/ctrl.nix
];

1
pkgs/clan-merge/.envrc Normal file
View File

@ -0,0 +1 @@
use flake .#clan-merge

12
pkgs/clan-merge/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
.direnv
result*
# python
__pycache__
.coverage
.mypy_cache
.pytest_cache
.pythonenv
.reports
.ruff_cache
htmlcov

View File

@ -0,0 +1,135 @@
import argparse
import json
import urllib.request
from os import environ
from typing import Optional
def load_token() -> str:
GITEA_TOKEN_FILE = environ.get("GITEA_TOKEN_FILE", default=None)
assert GITEA_TOKEN_FILE is not None
with open(GITEA_TOKEN_FILE, "r") as f:
return f.read().strip()
def is_ci_green(pr: dict) -> bool:
print("Checking CI status for PR " + str(pr["id"]))
url = (
"https://git.clan.lol/api/v1/repos/clan/"
+ pr["base"]["repo"]["name"]
+ "/commits/"
+ pr["head"]["sha"]
+ "/status"
)
response = urllib.request.urlopen(url)
data = json.loads(response.read())
# check for all commit statuses to have status "success"
for status in data["statuses"]:
if status["status"] != "success":
return False
return True
def decide_merge(pr: dict, allowed_users: list[str], bot_name: str) -> bool:
if (
pr["user"]["login"] in allowed_users
and pr["mergeable"] is True
and not pr["title"].startswith("WIP:")
and pr["state"] == "open"
# check if bot is assigned
and any(reviewer["login"] == bot_name for reviewer in pr["assignees"])
and is_ci_green(pr)
):
return True
return False
# python equivalent for: curl -X 'GET' https://git.clan.lol/api/v1/repos/clan/{repo}/pulls
def list_prs(repo: str) -> list:
url = "https://git.clan.lol/api/v1/repos/clan/" + repo + "/pulls"
response = urllib.request.urlopen(url)
data = json.loads(response.read())
return data
def list_prs_to_merge(prs: list, allowed_users: list[str], bot_name: str) -> list:
prs_to_merge = []
for pr in prs:
if decide_merge(pr, allowed_users, bot_name) is True:
prs_to_merge.append(pr)
return prs_to_merge
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Merge PRs on clan.lol")
# parse a list of allowed users
parser.add_argument(
"--allowed-users",
nargs="+",
help="list of users allowed to merge",
required=True,
)
# option for bot-name
parser.add_argument(
"--bot-name",
help="name of the bot",
required=True,
)
# parse list of repository names for which to merge PRs
parser.add_argument(
"--repos",
nargs="+",
help="list of repositories for which to merge PRs",
required=True,
)
# option for dry run
parser.add_argument(
"--dry-run",
action="store_true",
help="dry run",
)
return parser.parse_args()
def clan_merge(
args: Optional[argparse.Namespace] = None, gitea_token: Optional[str] = None
) -> None:
if gitea_token is None:
gitea_token = load_token()
if args is None:
args = parse_args()
allowed_users = args.allowed_users
repos = args.repos
dry_run = args.dry_run
bot_name = args.bot_name
for repo in repos:
prs = list_prs(repo)
prs_to_merge = list_prs_to_merge(prs, allowed_users, bot_name)
for pr in prs_to_merge:
url = (
"https://git.clan.lol/api/v1/repos/clan/"
+ repo
+ "/pulls/"
+ str(pr["number"])
+ "/merge"
+ f"?token={gitea_token}"
)
if dry_run is True:
print(
f"Would merge PR {pr['number']} in repo {repo} from user {pr['user']['login']}"
)
else:
print("Merging PR " + str(pr["id"]))
data = dict(
Do="merge",
)
data_encoded = json.dumps(data).encode("utf8")
print(data)
req = urllib.request.Request(
url, data=data_encoded, headers={"Content-Type": "application/json"}
)
urllib.request.urlopen(req)
if __name__ == "__main__":
clan_merge()

View File

@ -0,0 +1,60 @@
{ pkgs ? import <nixpkgs> { }
, lib ? pkgs.lib
, python3 ? pkgs.python3
, ruff ? pkgs.ruff
, runCommand ? pkgs.runCommand
,
}:
let
pyproject = builtins.fromTOML (builtins.readFile ./pyproject.toml);
name = pyproject.project.name;
src = lib.cleanSource ./.;
dependencies = lib.attrValues {
# inherit (python3.pkgs)
# some-package
# ;
};
devDependencies = lib.attrValues {
inherit (pkgs) ruff;
inherit (python3.pkgs)
black
mypy
pytest
pytest-cov
setuptools
wheel
;
};
package = python3.pkgs.buildPythonPackage {
inherit name src;
format = "pyproject";
nativeBuildInputs = [
python3.pkgs.setuptools
];
propagatedBuildInputs =
dependencies
++ [ ];
passthru.tests = { inherit check; };
passthru.devDependencies = devDependencies;
};
checkPython = python3.withPackages (_ps: devDependencies ++ dependencies);
check = runCommand "${name}-check" { } ''
cp -r ${src} ./src
chmod +w -R ./src
cd src
export PYTHONPATH=.
echo -e "\x1b[32m## run mypy\x1b[0m"
${checkPython}/bin/mypy .
echo -e "\x1b[32m## run pytest\x1b[0m"
${checkPython}/bin/pytest
touch $out
'';
in
package

View File

@ -0,0 +1,11 @@
{
perSystem = { pkgs, ... }:
let
package = pkgs.callPackage ./default.nix { inherit pkgs; };
in
{
packages.clan-merge = package;
checks.clan-merge = package.tests.check;
devShells.clan-merge = import ./shell.nix { inherit pkgs; };
};
}

View File

@ -0,0 +1,58 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["clan_merge*"]
[project]
name = "clan_merge"
description = "cLAN internal merge bot for gitea"
dynamic = ["version"]
scripts = {clan-merge = "clan_merge:clan_merge"}
[tool.pytest.ini_options]
addopts = "--cov . --cov-report term --cov-report=html:.reports/html --no-cov-on-fail"
[tool.mypy]
python_version = "3.10"
warn_redundant_casts = true
disallow_untyped_calls = true
disallow_untyped_defs = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = "setuptools.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "pytest.*"
ignore_missing_imports = true
[tool.ruff]
line-length = 88
select = ["E", "F", "I"]
ignore = [ "E501" ]
[tool.black]
line-length = 88
target-version = ['py310']
include = '\.pyi?$'
exclude = '''
/(
\.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
# The following are specific to Black, you probably don't want those.
| blib2to3
| tests/data
| profiling
)/
'''

44
pkgs/clan-merge/shell.nix Normal file
View File

@ -0,0 +1,44 @@
{ pkgs ? import <nixpkgs> { } }:
let
lib = pkgs.lib;
python3 = pkgs.python3;
package = import ./default.nix {
inherit lib pkgs python3;
};
pythonWithDeps = python3.withPackages (
ps:
package.propagatedBuildInputs
++ package.devDependencies
++ [
ps.pip
]
);
checkScript = pkgs.writeScriptBin "check" ''
nix build -f . tests -L "$@"
'';
devShell = pkgs.mkShell {
packages = [
pkgs.ruff
pythonWithDeps
];
# sets up an editable install and add enty points to $PATH
shellHook = ''
tmp_path=$(realpath ./.pythonenv)
repo_root=$(realpath .)
rm -rf $tmp_path
mkdir -p "$tmp_path/${pythonWithDeps.sitePackages}"
${pythonWithDeps.interpreter} -m pip install \
--quiet \
--disable-pip-version-check \
--no-index \
--no-build-isolation \
--prefix "$tmp_path" \
--editable $repo_root
export PATH="$tmp_path/bin:${checkScript}/bin:$PATH"
export PYTHONPATH="$repo_root:$tmp_path/${pythonWithDeps.sitePackages}"
'';
};
in
devShell

View File

@ -0,0 +1,114 @@
import pytest
import clan_merge
def test_no_args(capsys: pytest.CaptureFixture) -> None:
# handle EsystemExit via pytest.raises
with pytest.raises(SystemExit):
clan_merge.clan_merge(gitea_token="")
captured = capsys.readouterr()
assert captured.err.startswith("usage:")
def test_decide_merge_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(clan_merge, "is_ci_green", lambda x: True)
allowed_users = ["foo"]
bot_name = "some-bot-name"
pr = dict(
id=1,
user=dict(login="foo"),
title="Some PR Title",
mergeable=True,
state="open",
assignees=[dict(login=bot_name)],
)
assert clan_merge.decide_merge(pr, allowed_users, bot_name=bot_name) is True
def test_decide_merge_not_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(clan_merge, "is_ci_green", lambda x: True)
allowed_users = ["foo"]
pr1 = dict(
id=1,
user=dict(login="bar"),
title="Some PR Title",
mergeable=True,
state="open",
assignees=[dict(login="foo")],
)
pr2 = dict(
id=1,
user=dict(login="foo"),
title="WIP: xyz",
mergeable=True,
state="open",
assignees=[dict(login="foo")],
)
pr3 = dict(
id=1,
user=dict(login="foo"),
title="Some PR Title",
mergeable=False,
state="open",
assignees=[dict(login="foo")],
)
pr4 = dict(
id=1,
user=dict(login="foo"),
title="Some PR Title",
mergeable=True,
state="closed",
assignees=[dict(login="foo")],
)
pr5 = dict(
id=1,
user=dict(login="foo"),
title="Some PR Title",
mergeable=True,
state="open",
assignees=[dict(login="clan-bot")],
)
assert clan_merge.decide_merge(pr1, allowed_users, bot_name="some-bot") is False
assert clan_merge.decide_merge(pr2, allowed_users, bot_name="some-bot") is False
assert clan_merge.decide_merge(pr3, allowed_users, bot_name="some-bot") is False
assert clan_merge.decide_merge(pr4, allowed_users, bot_name="some-bot") is False
assert clan_merge.decide_merge(pr5, allowed_users, bot_name="some-bot") is False
def test_list_prs_to_merge(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(clan_merge, "is_ci_green", lambda x: True)
bot_name = "some-bot-name"
prs = [
dict(
id=1,
base=dict(repo=dict(name="repo1")),
head=dict(sha="1234567890"),
user=dict(login="foo"),
state="open",
title="PR 1",
mergeable=True,
assignees=[dict(login=bot_name)],
),
dict(
id=2,
base=dict(repo=dict(name="repo1")),
head=dict(sha="1234567890"),
user=dict(login="foo"),
state="open",
title="WIP: xyz",
mergeable=True,
assignees=[dict(login=bot_name)],
),
dict(
id=3,
base=dict(repo=dict(name="repo1")),
head=dict(sha="1234567890"),
user=dict(login="bar"),
state="open",
title="PR 2",
mergeable=True,
assignees=[dict(login=bot_name)],
),
]
assert clan_merge.list_prs_to_merge(prs, ["foo"], bot_name=bot_name) == [prs[0]]

View File

@ -11,6 +11,7 @@ harmonia-key: ENC[AES256_GCM,data:pZObqfbLogp0DYs47Tg2STKT9HptPSiP4sgcf31FD68PKS
matrix-server-key: ENC[AES256_GCM,data:0148ezOFk8jX5KPQPCG0jQK9ajSfe/iOdUqlvys5/M8DrIwPXH9GzrkknwH+l8kF9ViTRDC/q5md8J2bj3/FBR/RW4rwjDrYx9cBEFm8wjHrywUlwON8kNKtj9ycJmXgtRyCrVGv7sBmODy0ZC5ZfWbhIQh6xWBkX2/rsSh4zwi/1PoHLpOO3u4=,iv:IwHPDi1E3R9LAY/seGpvx1U+N8mB9NMrUjLg4KMA1UA=,tag:pwRJ/CqkFN2eedrnMAaj2w==,type:str]
registration-secret: ENC[AES256_GCM,data:EvPearZAxxb2irZFYgvy/tFA72h+IABuzwCbvy94IYR0eoHjuYw6GBde8CNUWG4SUiwyXJr4v438o/YThDhehsZ/cZFjg2o=,iv:ogN4/Iia5Zl95a3HP1KZoy86K8LyBFYw50cZUpkDNQo=,tag:5wU2OrNi7b5gWPfFZcGLjg==,type:str]
gitea-actions-runner: ENC[AES256_GCM,data:JKXAa7J1V3GH8lp3UtHTBmiezJlqxX1ItHLE7UcaIeNFQH8We2imaOMVftMpVCeXTpRX,iv:W9+4wH4asw3+w28i5om0OcJFHrABC85bhjhbgGWEs8E=,tag:Rf9XBeiEoJ1Pt8Z1TDIyJA==,type:str]
merge-bot-gitea-token: ENC[AES256_GCM,data:ULHcaNSYJwMVeeEq4bSiRcVRuUkE9fFUV0AkWW1wM0yHQtD+dmo1GcQ=,iv:dujDWGZ+seoVN8Eez1w3tUuMpGeOHtNLMaa+f2hOpAo=,tag:WoDTsZegC6rrbh7ygWSk+A==,type:str]
sops:
kms: []
gcp_kms: []
@ -53,8 +54,8 @@ sops:
TGk4dUlwcE9XWWIzZE1nQXdXcWY0V0kKJi5yXdrsEOP4Z8K6k/sPA7yadNPKQtzo
Iyt//Y+Y7n55KwuO8Doogu42SiVTUhHDICM9lezQmcugFqCoh3Lk4A==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2023-07-21T13:58:56Z"
mac: ENC[AES256_GCM,data:GD2lZplaOjw2vRYYAIFydFK1NndJRv5MeXNHDCr/H7G5t8jnO2XstOuUYLhzqO1lpL2dRi4vc+B0UuM6jS3mzUkUqfV201qQ4MxDnViYxgNRk+7XuVaM940yw4UwUJQA2IN7C9EOU/xmYRqpvHFWptjrGFkEnBEVChKncqpen4k=,iv:Zn9i3Y7pkz5OsGHeOi2VBuF2Ha0dUDbDJl+BhXKMgaI=,tag:azmGDxfkQ9P49QTQBxdjSQ==,type:str]
lastmodified: "2023-07-26T09:49:19Z"
mac: ENC[AES256_GCM,data:sAJcUwJeVCXwNXmWUJrP0L1UcjoYDqErW2mBTRC3yoUOVtbVdZnLkswO0PARWruOqMBKXkIH/SqeiLyJ7HLIsobBzFoUNQ6TgjmP0OHf4Qbo/5sSDVA95qK1ZCgK93uKSEfG0WUvJLqfOKUEdBUgPUqJ58RM2VOWU21liccaG+A=,iv:u6lStYbzZsOWd5rsZXKs0XCAbQTFsPrnXLqO27i/Qt0=,tag:JeYtuP1zztsy4FUB1kzcWw==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.7.3