From 0c56c51999735263fbd6c3403b6cc0089627625c Mon Sep 17 00:00:00 2001 From: Maxwell G Date: Tue, 25 Jul 2023 10:20:26 -0500 Subject: [PATCH] Add Github Action to label issues and PRs (#118) --- .github/workflows/labeler.yml | 46 +++++++++ hacking/pr_labeler/label.py | 148 ++++++++++++++++++++++++++++ hacking/pr_labeler/requirements.txt | 3 + 3 files changed, 197 insertions(+) create mode 100644 .github/workflows/labeler.yml create mode 100644 hacking/pr_labeler/label.py create mode 100644 hacking/pr_labeler/requirements.txt diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000000..32bf7e209b0 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,46 @@ +--- +# Copyright (C) 2023 Maxwell G +# SPDX-License-Identifier: GPL-3.0-or-later + +"on": + pull_request_target: + issues: + types: + - opened + +name: "Triage Issues and PRs" + +permissions: + issues: write + pull-requests: write + +jobs: + label_prs: + runs-on: ubuntu-latest + name: "Label Issue/PR" + steps: + - name: Print event information + run: | + echo "${{ toJSON(github.event) }}" + - name: Checkout parent repository + uses: actions/checkout@v3 + - name: Install Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Setup venv + run: | + python -m venv venv + ./venv/bin/pip install -r hacking/pr_labeler/requirements.txt + - name: "Run the issue labeler" + if: "github.event.issue" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: + ./venv/bin/python hacking/pr_labeler/label.py issue ${{ github.event.issue.number }} + - name: "Run the PR labeler" + if: "! github.event.issue" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: + ./venv/bin/python hacking/pr_labeler/label.py pr ${{ github.event.number }} diff --git a/hacking/pr_labeler/label.py b/hacking/pr_labeler/label.py new file mode 100644 index 00000000000..a92b561c6ac --- /dev/null +++ b/hacking/pr_labeler/label.py @@ -0,0 +1,148 @@ +# Copyright (C) 2023 Maxwell G +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import dataclasses +import os +from collections.abc import Collection +from functools import cache +from pathlib import Path +from typing import Union + +import github +import github.Auth +import github.Issue +import github.PullRequest +import github.Repository +import typer +from codeowners import CodeOwners, OwnerTuple + +OWNER = "ansible" +REPO = "ansible-documentation" +LABELS_BY_CODEOWNER: dict[OwnerTuple, list[str]] = { + ("TEAM", "@ansible/steering-committee"): ["sc_approval"], +} +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +CODEOWNERS = (ROOT / ".github/CODEOWNERS").read_text("utf-8") + +IssueOrPrCtx = Union["IssueLabelerCtx", "PRLabelerCtx"] +IssueOrPr = Union["github.Issue.Issue", "github.PullRequest.PullRequest"] + + +def get_repo(authed: bool = True) -> tuple[github.Github, github.Repository.Repository]: + gclient = github.Github( + auth=github.Auth.Token(os.environ["GITHUB_TOKEN"]) if authed else None, + ) + repo = gclient.get_repo(f"{OWNER}/{REPO}") + return gclient, repo + + +@dataclasses.dataclass(frozen=True) +class LabelerCtx: + client: github.Github + repo: github.Repository.Repository + dry_run: bool + + @property + def member(self) -> IssueOrPr: + raise NotImplementedError + + +@dataclasses.dataclass(frozen=True) +class IssueLabelerCtx(LabelerCtx): + issue: github.Issue.Issue + + @property + def member(self) -> IssueOrPr: + return self.issue + + +@dataclasses.dataclass(frozen=True) +class PRLabelerCtx(LabelerCtx): + pr: github.PullRequest.PullRequest + + @property + def member(self) -> IssueOrPr: + return self.pr + + +@cache +def get_previously_labeled(ctx: IssueOrPrCtx) -> frozenset[str]: + previously_labeled: set[str] = set() + events = ( + ctx.issue.get_events() + if isinstance(ctx, IssueLabelerCtx) + else ctx.pr.get_issue_events() + ) + for event in events: + if event.event in ("labeled", "unlabeled"): + assert event.label + previously_labeled.add(event.label.name) + return frozenset(previously_labeled) + + +def handle_codeowner_labels(ctx: PRLabelerCtx) -> None: + labels = LABELS_BY_CODEOWNER.copy() + owners = CodeOwners(CODEOWNERS) + files = ctx.pr.get_files() + for file in files: + for owner in owners.of(file.filename): + if labels_to_add := labels.pop(owner, None): + add_label_if_new(ctx, labels_to_add) + if not labels: + return + + +def add_label_if_new(ctx: IssueOrPrCtx, labels: Collection[str] | str) -> None: + """ + Add a label to a PR if it wasn't added in the past + """ + labels = {labels} if isinstance(labels, str) else labels + previously_labeled = get_previously_labeled(ctx) + labels = set(labels) - previously_labeled + if not labels: + return + print(f"Adding labels to {ctx.member.number}:", *map(repr, labels)) + if not ctx.dry_run: + ctx.member.add_to_labels(*labels) + + +APP = typer.Typer() + + +@APP.callback() +def cb(): + """ + Basic triager for ansible/ansible-documentation + """ + + +@APP.command(name="pr") +def process_pr(pr_number: int, dry_run: bool = False) -> None: + gclient, repo = get_repo(authed=not dry_run) + pr = repo.get_pull(pr_number) + if pr.state != "open": + print("Refusing to process closed ticket") + return + ctx = PRLabelerCtx(client=gclient, repo=repo, pr=pr, dry_run=dry_run) + + handle_codeowner_labels(ctx) + add_label_if_new(ctx, "needs_triage") + + +@APP.command(name="issue") +def process_issue(issue_number: int, dry_run: bool = False) -> None: + gclient, repo = get_repo(authed=not dry_run) + issue = repo.get_issue(issue_number) + if issue.state != "open": + print("Refusing to process closed ticket") + return + ctx = IssueLabelerCtx(client=gclient, repo=repo, issue=issue, dry_run=dry_run) + + add_label_if_new(ctx, "needs_triage") + + +if __name__ == "__main__": + APP() diff --git a/hacking/pr_labeler/requirements.txt b/hacking/pr_labeler/requirements.txt new file mode 100644 index 00000000000..6765d031ea0 --- /dev/null +++ b/hacking/pr_labeler/requirements.txt @@ -0,0 +1,3 @@ +codeowners +pygithub +typer