diff --git a/.github/scripts/request_review.py b/.github/scripts/request_review.py new file mode 100644 index 000000000..1a53e82e4 --- /dev/null +++ b/.github/scripts/request_review.py @@ -0,0 +1,115 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "githubkit", +# ] +# /// + +import asyncio +import os +import re +from itertools import chain + +from githubkit import GitHub + +ORG_NAME = "ghostty-org" +REPO_NAME = "ghostty" +ALLOWED_PARENT_TEAM = "localization" +LOCALIZATION_TEAM_NAME_PATTERN = re.compile(r"[a-z]{2}_[A-Z]{2}") + +gh = GitHub(os.environ["GITHUB_TOKEN"]) + + +async def fetch_and_parse_codeowners() -> dict[str, str]: + content = ( + await gh.rest.repos.async_get_content( + ORG_NAME, + REPO_NAME, + "CODEOWNERS", + headers={"Accept": "application/vnd.github.raw+json"}, + ) + ).text + + codeowners: dict[str, str] = {} + for line in content.splitlines(): + if not line or line.lstrip().startswith("#"): + continue + # This assumes that all entries only list one owner + # and that this owner is a team (ghostty-org/foobar) + path, owner = line.split() + codeowners[path.lstrip("/")] = owner.removeprefix(f"@{ORG_NAME}/") + return codeowners + + +async def get_team_members(team_name: str) -> list[str]: + team = (await gh.rest.teams.async_get_by_name(ORG_NAME, team_name)).parsed_data + if team.parent and team.parent.slug == ALLOWED_PARENT_TEAM: + members = ( + await gh.rest.teams.async_list_members_in_org(ORG_NAME, team_name) + ).parsed_data + return [m.login for m in members] + return [] + + +async def get_changed_files(pr_number: int) -> list[str]: + diff_entries = ( + await gh.rest.pulls.async_list_files( + ORG_NAME, + REPO_NAME, + pr_number, + per_page=3000, + headers={"Accept": "application/vnd.github+json"}, + ) + ).parsed_data + return [d.filename for d in diff_entries] + + +async def request_review(pr_number: int, pr_author: str, *users: str) -> None: + await asyncio.gather( + *( + gh.rest.pulls.async_request_reviewers( + ORG_NAME, + REPO_NAME, + pr_number, + headers={"Accept": "application/vnd.github+json"}, + data={"reviewers": [user]}, + ) + for user in users + if user != pr_author + ) + ) + + +def is_localization_team(team_name: str) -> bool: + return LOCALIZATION_TEAM_NAME_PATTERN.fullmatch(team_name) is not None + + +async def main() -> None: + pr_number = int(os.environ["PR_NUMBER"]) + changed_files = await get_changed_files(pr_number) + pr_author = ( + await gh.rest.pulls.async_get(ORG_NAME, REPO_NAME, pr_number) + ).parsed_data.user.login + localization_codewners = { + path: owner + for path, owner in (await fetch_and_parse_codeowners()).items() + if is_localization_team(owner) + } + + found_owners = set[str]() + for file in changed_files: + for path, owner in localization_codewners.items(): + if file.startswith(path): + break + else: + continue + found_owners.add(owner) + + member_lists = await asyncio.gather( + *(get_team_members(owner) for owner in found_owners) + ) + await request_review(pr_number, pr_author, *chain.from_iterable(member_lists)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 000000000..9abe0b5e2 --- /dev/null +++ b/.github/workflows/review.yml @@ -0,0 +1,37 @@ +name: Request Review + +on: + pull_request: + types: + - opened + - synchronize + +env: + PY_COLORS: 1 + +jobs: + review: + runs-on: namespace-profile-ghostty-xsm + steps: + - uses: actions/checkout@v4 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@v1.2.0 + with: + path: | + /nix + /zig + + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Request Localization Review + env: + GITHUB_TOKEN: ${{ secrets.GH_REVIEW_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: nix develop -c uv run .github/scripts/request_review.py diff --git a/flake.nix b/flake.nix index c8e53d7e9..d4c6aa6ca 100644 --- a/flake.nix +++ b/flake.nix @@ -51,6 +51,7 @@ devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { zig = zig.packages.${system}."0.14.0"; wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; + uv = pkgs-unstable.uv; # remove once blueprint-compiler 0.16.0 is in the stable nixpkgs blueprint-compiler = pkgs-unstable.blueprint-compiler; zon2nix = zon2nix; diff --git a/nix/devShell.nix b/nix/devShell.nix index 6949744d0..5b69f882b 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -57,6 +57,7 @@ pandoc, hyperfine, typos, + uv, wayland, wayland-scanner, wayland-protocols, @@ -109,6 +110,9 @@ in # Localization gettext + # CI + uv + # We need these GTK-related deps on all platform so we can build # dist tarballs. blueprint-compiler