Skip to content

Commit

Permalink
[stable-2.15] Add Github Action to label issues and PRs (#167)
Browse files Browse the repository at this point in the history
* 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
gotmax23 authored Jul 25, 2023
1 parent 897b949 commit f189b38
Show file tree
Hide file tree
Showing 4 changed files with 254 additions and 0 deletions.
46 changes: 46 additions & 0 deletions .github/workflows/labeler.yml
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 }}
2 changes: 2 additions & 0 deletions hacking/pr_labeler/data/docs_team_info.md
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 --->
203 changes: 203 additions & 0 deletions hacking/pr_labeler/label.py
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()
3 changes: 3 additions & 0 deletions hacking/pr_labeler/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
codeowners
pygithub
typer

0 comments on commit f189b38

Please sign in to comment.