-
Notifications
You must be signed in to change notification settings - Fork 523
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[stable-2.15] Add Github Action to label issues and PRs (#167)
* Add Github Action to label issues and PRs (#118) * labeler: welcome new contributors Fixes: #69 * labeler: improve logging * labeler: add --authed-dry-run flag Some data such as author_association is only available to authenticated API users. This helps with testing. * ci labeler: fix `Print event information` step (#166) * labeler: fix log() type annotations (#165) print() coerces any object to a str.
- Loading branch information
Showing
4 changed files
with
254 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
--- | ||
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me> | ||
# 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 }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
Thanks for your Ansible docs contribution! We talk about Ansible documentation on matrix at [#docs:ansible.im](https://matrix.to/#/#docs:ansible.im) and on libera IRC at #ansible-docs if you ever want to join us and chat about the docs! We meet there on Tuesdays (see [the Ansible calendar](https://github.com/ansible/community/blob/main/meetings/README.md)) and welcome additions to our [weekly agenda items](https://github.com/ansible/community/issues/678) - scroll down to find the upcoming agenda and add a comment to put something new on that agenda. | ||
<!--- boilerplate: docs_team_info ---> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me> | ||
# 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"] | ||
|
||
|
||
# TODO: If we end up needing to log more things with more granularity, | ||
# switch to something like `logging` | ||
def log(ctx: IssueOrPrCtx, *args: object) -> None: | ||
print(f"{ctx.member.number}:", *args) | ||
|
||
|
||
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 create_comment(ctx: IssueOrPrCtx, body: str) -> None: | ||
if ctx.dry_run: | ||
return | ||
if isinstance(ctx, IssueLabelerCtx): | ||
ctx.issue.create_comment(body) | ||
else: | ||
ctx.pr.create_issue_comment(body) | ||
|
||
|
||
def get_data_file(name: str) -> str: | ||
""" | ||
Get a data file | ||
""" | ||
return (HERE / "data" / name).read_text("utf-8") | ||
|
||
|
||
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 | ||
log(ctx, "Adding labels", *map(repr, labels)) | ||
if not ctx.dry_run: | ||
ctx.member.add_to_labels(*labels) | ||
|
||
|
||
def new_contributor_welcome(ctx: IssueOrPrCtx) -> None: | ||
""" | ||
Welcome a new contributor to the repo with a message and a label | ||
""" | ||
previously_labeled = get_previously_labeled(ctx) | ||
# This contributor has already been welcomed! | ||
if "new_contributor" in previously_labeled: | ||
return | ||
log(ctx, "author_association is", ctx.member.raw_data["author_association"]) | ||
if ctx.member.raw_data["author_association"] not in { | ||
"FIRST_TIMER", | ||
"FIRST_TIME_CONTRIBUTOR", | ||
}: | ||
return | ||
log(ctx, "Welcoming new contributor") | ||
add_label_if_new(ctx, "new_contributor") | ||
create_comment(ctx, get_data_file("docs_team_info.md")) | ||
|
||
|
||
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, authed_dry_run: bool = False | ||
) -> None: | ||
authed = not dry_run | ||
if authed_dry_run: | ||
dry_run = True | ||
authed = True | ||
gclient, repo = get_repo(authed=authed) | ||
pr = repo.get_pull(pr_number) | ||
ctx = PRLabelerCtx(client=gclient, repo=repo, pr=pr, dry_run=dry_run) | ||
if pr.state != "open": | ||
log(ctx, "Refusing to process closed ticket") | ||
return | ||
|
||
handle_codeowner_labels(ctx) | ||
add_label_if_new(ctx, "needs_triage") | ||
new_contributor_welcome(ctx) | ||
|
||
|
||
@APP.command(name="issue") | ||
def process_issue( | ||
issue_number: int, dry_run: bool = False, authed_dry_run: bool = False | ||
) -> None: | ||
authed = not dry_run | ||
if authed_dry_run: | ||
dry_run = True | ||
authed = True | ||
gclient, repo = get_repo(authed=authed) | ||
issue = repo.get_issue(issue_number) | ||
ctx = IssueLabelerCtx(client=gclient, repo=repo, issue=issue, dry_run=dry_run) | ||
if issue.state != "open": | ||
log(ctx, "Refusing to process closed ticket") | ||
return | ||
|
||
add_label_if_new(ctx, "needs_triage") | ||
new_contributor_welcome(ctx) | ||
|
||
|
||
if __name__ == "__main__": | ||
APP() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
codeowners | ||
pygithub | ||
typer |