[stable-2.15] Backport pr_labeler changes (#1917)

* pr_labeler: improve create_boilerplate_comment logging

(cherry picked from commit 5730ba9a01)

* pr_labeler: add --force-process-closed flag

(cherry picked from commit 44ffe0f210)

* pr_labeler: add warning for porting_guides changes

This adds a warning message when PRs are created that edit
porting_guides by someone outside of the Release Management WG. These
files are automatically generated during the ansible release process and
should not be modified.

Fixes: https://github.com/ansible/ansible-documentation/issues/503
(cherry picked from commit d2e6625e8b)

* pr_labeler: use @release-management-wg team for porting_guide check

Instead of hardcoding the list of release managers, we can use the
Github API to retrieve the members of the
`@ansible/release-management-wg` team.

(cherry picked from commit dddfd7eb55)

* pr_labeler: exempt bots from porting_guide check

For example, patchback is not a release manager, but we still want it to
backport Porting Guide PRs.

(cherry picked from commit 746662c255)

* pr_labeler: improve porting_guide_changes template wording

Co-authored-by: Sandra McCann <samccann@redhat.com>
(cherry picked from commit 95ece7e9d6)

* pr_labeler: refactor new_contributor_welcome code (#990)

* pr_labeler: add GlobalArgs.full_repo property

* pr_labeler: refactor new_contributor_welcome code

As of https://github.com/ansible/ansible-documentation/issues/69, the
pr_labeler responds with a welcome message when an issue or PR is opened
by a new contributor. It turns out this never actually worked properly.

The previous method that relied on Github's `author_association` flag
did not work with the app token that the pr_labeler uses. This refactors
the code to figure out whether a user is a new contributor by
searching the list of issues and PRs.

Fixes: https://github.com/ansible/ansible-documentation/issues/204

* pr_labeler: address potential race condition

(cherry picked from commit 763815d1ad)

* Bump actions/setup-python from 4 to 5 (#966)

Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

(cherry picked from commit 466b1fdc43)

* pr_labeler: re-architect triager script (#1882)

This commit reorganizes the issue/PR triager script and updates the
workflow to run more efficiently.

- Make the script a proper Python package instead of an unwieldy single
  file
- Use locked dependencies and UV to decrease workflow runtime to under
  10 seconds.

(cherry picked from commit 7138e42716)
(cherry picked from commit 1cf9f7917b)

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Maxwell G
2024-09-20 04:49:02 -05:00
committed by GitHub
parent 3095c933c1
commit 3ca918722b
22 changed files with 648 additions and 330 deletions

View File

@@ -41,13 +41,16 @@ jobs:
- name: Checkout parent repository
uses: actions/checkout@v4
- name: Install Python 3.11
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Set up UV
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Setup venv
run: |
python -m venv venv
./venv/bin/pip install -r hacking/pr_labeler/requirements.txt
uv venv venv
uv pip install --python venv \
-e hacking/pr_labeler -c tests/pr_labeler.txt
- name: "Run the issue labeler"
if: "github.event.issue || inputs.type == 'issue'"
env:
@@ -55,7 +58,7 @@ jobs:
GITHUB_TOKEN: ${{ steps.create_token.outputs.token }}
number: "${{ github.event.issue.number || inputs.number }}"
run: |
./venv/bin/python hacking/pr_labeler/label.py issue "${number}"
./venv/bin/ad-triage issue "${number}"
- name: "Run the PR labeler"
if: "github.event.pull_request || inputs.type == 'pr'"
env:
@@ -63,4 +66,4 @@ jobs:
GITHUB_TOKEN: ${{ steps.create_token.outputs.token }}
number: "${{ github.event.number || inputs.number }}"
run: |
./venv/bin/python hacking/pr_labeler/label.py pr "${number}"
./venv/bin/ad-triage pr "${number}"

1
hacking/pr_labeler/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.egg-info/

View File

@@ -1,310 +0,0 @@
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import dataclasses
import json
import os
from collections.abc import Collection
from contextlib import suppress
from functools import cached_property
from pathlib import Path
from typing import Any, ClassVar, Union
import github
import github.Auth
import github.Issue
import github.PullRequest
import github.Repository
import typer
from codeowners import CodeOwners, OwnerTuple
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
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")
JINJA2_ENV = Environment(
loader=FileSystemLoader(HERE / "data"),
autoescape=select_autoescape(),
trim_blocks=True,
undefined=StrictUndefined,
)
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, owner: str, repo: str
) -> tuple[github.Github, github.Repository.Repository]:
gclient = github.Github(
auth=github.Auth.Token(os.environ["GITHUB_TOKEN"]) if authed else None,
)
repo_obj = gclient.get_repo(f"{owner}/{repo}")
return gclient, repo_obj
def get_event_info() -> dict[str, Any]:
event_json = os.environ.get("event_json")
if not event_json:
return {}
with suppress(json.JSONDecodeError):
return json.loads(event_json)
return {}
@dataclasses.dataclass()
class GlobalArgs:
owner: str
repo: str
@dataclasses.dataclass()
class LabelerCtx:
client: github.Github
repo: github.Repository.Repository
dry_run: bool
event_info: dict[str, Any]
issue: github.Issue.Issue
TYPE: ClassVar[str]
@property
def member(self) -> IssueOrPr:
raise NotImplementedError
@property
def event_member(self) -> dict[str, Any]:
raise NotImplementedError
@cached_property
def previously_labeled(self) -> frozenset[str]:
labels: set[str] = set()
events = (
self.member.get_events()
if isinstance(self.member, github.Issue.Issue)
else self.member.get_issue_events()
)
for event in events:
if event.event in ("labeled", "unlabeled"):
assert event.label
labels.add(event.label.name)
return frozenset(labels)
@dataclasses.dataclass()
class IssueLabelerCtx(LabelerCtx):
issue: github.Issue.Issue
TYPE = "issue"
@property
def member(self) -> IssueOrPr:
return self.issue
@property
def event_member(self) -> dict[str, Any]:
return self.event_info.get("issue", {})
@dataclasses.dataclass()
class PRLabelerCtx(LabelerCtx):
pr: github.PullRequest.PullRequest
TYPE = "pull request"
@property
def member(self) -> IssueOrPr:
return self.pr
@property
def event_member(self) -> dict[str, Any]:
return self.event_info.get("pull_request", {})
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, **kwargs: Any) -> str:
"""
Template a data file
"""
return JINJA2_ENV.get_template(name).render(**kwargs).rstrip("\n")
def create_boilerplate_comment(ctx: IssueOrPrCtx, name: str, **kwargs) -> None:
"""
Add a boilerplate comment if it hasn't already been added
"""
tmpl = get_data_file(name, ctx=ctx, **kwargs)
tmpl_lines = tmpl.splitlines()
last = tmpl_lines[-1]
if not (last.startswith("<!--- boilerplate: ") and last.endswith(" --->")):
raise ValueError(
"Last line must of the template"
" must have an identifying boilerplate comment"
)
for comment in ctx.issue.get_comments():
if comment.body.splitlines()[-1] == last:
log(ctx, name, "boilerplate was already commented")
return
log(ctx, "Templating", name, "boilerplate")
create_comment(ctx, tmpl)
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
labels = set(labels) - ctx.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
"""
# This contributor has already been welcomed!
if "new_contributor" in ctx.previously_labeled:
return
author_association = ctx.event_member.get(
"author_association", ctx.member.raw_data["author_association"]
)
log(ctx, "author_association is", author_association)
if 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"))
def no_body_nag(ctx: IssueOrPrCtx) -> None:
"""
Complain if a non-bot user creates a PR or issue without body text
"""
if ctx.member.user.login.endswith("[bot]") or (ctx.member.body or "").strip():
return
create_boilerplate_comment(ctx, "no_body_nag.md")
APP = typer.Typer()
@APP.callback()
def cb(*, click_ctx: typer.Context, owner: str = OWNER, repo: str = REPO):
"""
Basic triager for ansible/ansible-documentation
"""
click_ctx.obj = GlobalArgs(owner, repo)
@APP.command(name="pr")
def process_pr(
*,
click_ctx: typer.Context,
pr_number: int,
dry_run: bool = False,
authed_dry_run: bool = False,
) -> None:
global_args = click_ctx.ensure_object(GlobalArgs)
authed = not dry_run
if authed_dry_run:
dry_run = True
authed = True
gclient, repo = get_repo(
authed=authed, owner=global_args.owner, repo=global_args.repo
)
pr = repo.get_pull(pr_number)
ctx = PRLabelerCtx(
client=gclient,
repo=repo,
pr=pr,
dry_run=dry_run,
event_info=get_event_info(),
issue=pr.as_issue(),
)
if pr.state != "open":
log(ctx, "Refusing to process closed ticket")
return
handle_codeowner_labels(ctx)
new_contributor_welcome(ctx)
no_body_nag(ctx)
@APP.command(name="issue")
def process_issue(
*,
click_ctx: typer.Context,
issue_number: int,
dry_run: bool = False,
authed_dry_run: bool = False,
) -> None:
global_args = click_ctx.ensure_object(GlobalArgs)
authed = not dry_run
if authed_dry_run:
dry_run = True
authed = True
gclient, repo = get_repo(
authed=authed, owner=global_args.owner, repo=global_args.repo
)
issue = repo.get_issue(issue_number)
ctx = IssueLabelerCtx(
client=gclient,
repo=repo,
issue=issue,
dry_run=dry_run,
event_info=get_event_info(),
)
if issue.state != "open":
log(ctx, "Refusing to process closed ticket")
return
add_label_if_new(ctx, "needs_triage")
new_contributor_welcome(ctx)
no_body_nag(ctx)
if __name__ == "__main__":
APP()

View File

@@ -0,0 +1,13 @@
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Module entrypoint
"""
from __future__ import annotations
from .cli import APP
if __name__ == "__main__":
APP()

View File

@@ -0,0 +1,138 @@
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Triager action functions
"""
from __future__ import annotations
import re
from collections.abc import Callable, Collection
from typing import TYPE_CHECKING
import github
from codeowners import CodeOwners
from .constants import CODEOWNERS, LABELS_BY_CODEOWNER, NEW_CONTRIBUTOR_LABEL
from .github_utils import (
create_comment,
get_team_members,
is_new_contributor_assoc,
is_new_contributor_manual,
)
from .jinja import get_data_file
from .utils import log
if TYPE_CHECKING:
from .cli_context import IssueOrPrCtx, PRLabelerCtx
def create_boilerplate_comment(ctx: IssueOrPrCtx, name: str, **kwargs) -> None:
"""
Add a boilerplate comment if it hasn't already been added
"""
tmpl = get_data_file(name, ctx=ctx, **kwargs)
tmpl_lines = tmpl.splitlines()
last = tmpl_lines[-1]
if not (last.startswith("<!--- boilerplate: ") and last.endswith(" --->")):
raise ValueError(
"Last line must of the template"
" must have an identifying boilerplate comment"
)
for comment in ctx.issue.get_comments():
if comment.body.splitlines()[-1] == last:
log(ctx, name, "boilerplate was already commented")
return
msg = f"Templating {name} boilerplate"
if kwargs:
msg += f" with {kwargs}"
log(ctx, msg)
create_comment(ctx, tmpl)
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
labels = set(labels) - ctx.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 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 new_contributor_welcome(ctx: IssueOrPrCtx) -> None:
"""
Welcome a new contributor to the repo with a message and a label
"""
is_new_contributor: Callable[[IssueOrPrCtx], bool] = (
is_new_contributor_assoc
if ctx.global_args.use_author_association
else is_new_contributor_manual
)
if (
# Contributor has already been welcomed
NEW_CONTRIBUTOR_LABEL in ctx.previously_labeled
#
or not is_new_contributor(ctx)
):
return
log(ctx, "Welcoming new contributor")
add_label_if_new(ctx, NEW_CONTRIBUTOR_LABEL)
create_comment(ctx, get_data_file("docs_team_info.md"))
def warn_porting_guide_change(ctx: PRLabelerCtx) -> None:
"""
Complain if a non-bot user outside of the Release Management WG changes
porting_guide
"""
user = ctx.pr.user.login
if user.endswith("[bot]"):
return
# If the API token does not have permisisons to view teams in the ansible
# org, fall back to an empty list.
members = []
try:
members = get_team_members(ctx, "release-management-wg")
except github.UnknownObjectException:
log(ctx, "Failed to get members of @ansible/release-management-wg")
if user in members:
return
matches: list[str] = []
for file in ctx.pr.get_files():
if re.fullmatch(
# Match community porting guides but not core porting guides
r"docs/docsite/rst/porting_guides/porting_guide_\d.*.rst",
file.filename,
):
matches.append(file.filename)
if not matches:
return
create_boilerplate_comment(ctx, "porting_guide_changes.md", changed_files=matches)
def no_body_nag(ctx: IssueOrPrCtx) -> None:
"""
Complain if a non-bot user creates a PR or issue without body text
"""
if ctx.member.user.login.endswith("[bot]") or (ctx.member.body or "").strip():
return
create_boilerplate_comment(ctx, "no_body_nag.md")

View File

@@ -0,0 +1,113 @@
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
# SPDX-License-Identifier: GPL-3.0-or-later
"""
CLI entrypoints
"""
from __future__ import annotations
import typer
from .actions import (
add_label_if_new,
handle_codeowner_labels,
new_contributor_welcome,
no_body_nag,
warn_porting_guide_change,
)
from .cli_context import GlobalArgs, IssueLabelerCtx, PRLabelerCtx
from .constants import OWNER, REPO
from .github_utils import get_event_info, get_repo
from .utils import log
APP = typer.Typer()
@APP.callback()
def cb(
*,
click_ctx: typer.Context,
owner: str = OWNER,
repo: str = REPO,
use_author_association: bool = False,
):
"""
Basic triager for ansible/ansible-documentation
"""
click_ctx.obj = GlobalArgs(owner, repo, use_author_association)
@APP.command(name="pr")
def process_pr(
*,
click_ctx: typer.Context,
pr_number: int,
dry_run: bool = False,
authed_dry_run: bool = False,
force_process_closed: bool = False,
) -> None:
global_args = click_ctx.ensure_object(GlobalArgs)
authed = not dry_run
if authed_dry_run:
dry_run = True
authed = True
gclient, repo = get_repo(global_args.full_repo, authed)
pr = repo.get_pull(pr_number)
ctx = PRLabelerCtx(
client=gclient,
repo=repo,
pr=pr,
dry_run=dry_run,
event_info=get_event_info(),
issue=pr.as_issue(),
global_args=global_args,
)
if not force_process_closed and pr.state != "open":
log(ctx, "Refusing to process closed ticket")
return
handle_codeowner_labels(ctx)
new_contributor_welcome(ctx)
no_body_nag(ctx)
warn_porting_guide_change(ctx)
@APP.command(name="issue")
def process_issue(
*,
click_ctx: typer.Context,
issue_number: int,
dry_run: bool = False,
authed_dry_run: bool = False,
force_process_closed: bool = False,
) -> None:
global_args = click_ctx.ensure_object(GlobalArgs)
authed = not dry_run
if authed_dry_run:
dry_run = True
authed = True
gclient, repo = get_repo(global_args.full_repo, authed)
issue = repo.get_issue(issue_number)
ctx = IssueLabelerCtx(
client=gclient,
repo=repo,
issue=issue,
dry_run=dry_run,
event_info=get_event_info(),
global_args=global_args,
)
if not force_process_closed and issue.state != "open":
log(ctx, "Refusing to process closed ticket")
return
add_label_if_new(ctx, "needs_triage")
new_contributor_welcome(ctx)
no_body_nag(ctx)
if __name__ == "__main__":
APP()

View File

@@ -0,0 +1,99 @@
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
# SPDX-License-Identifier: GPL-3.0-or-later
"""
CLI context objects
"""
from __future__ import annotations
import dataclasses
from functools import cached_property
from typing import TYPE_CHECKING, Any, ClassVar
import github
import github.Issue
import github.PullRequest
import github.Repository
if TYPE_CHECKING:
from typing_extensions import TypeAlias
from .github_utils import IssueOrPr
IssueOrPrCtx: TypeAlias = "IssueLabelerCtx | PRLabelerCtx"
@dataclasses.dataclass()
class GlobalArgs:
owner: str
repo: str
use_author_association: bool
@property
def full_repo(self) -> str:
return f"{self.owner}/{self.repo}"
@dataclasses.dataclass()
class LabelerCtx:
client: github.Github
repo: github.Repository.Repository
dry_run: bool
event_info: dict[str, Any]
issue: github.Issue.Issue
global_args: GlobalArgs
TYPE: ClassVar[str]
@property
def member(self) -> IssueOrPr:
raise NotImplementedError
@property
def event_member(self) -> dict[str, Any]:
raise NotImplementedError
@cached_property
def previously_labeled(self) -> frozenset[str]:
labels: set[str] = set()
events = (
self.member.get_events()
if isinstance(self.member, github.Issue.Issue)
else self.member.get_issue_events()
)
for event in events:
if event.event in ("labeled", "unlabeled"):
assert event.label
labels.add(event.label.name)
return frozenset(labels)
@dataclasses.dataclass()
class IssueLabelerCtx(LabelerCtx):
issue: github.Issue.Issue
TYPE = "issue"
@property
def member(self) -> IssueOrPr:
return self.issue
@property
def event_member(self) -> dict[str, Any]:
return self.event_info.get("issue", {})
@dataclasses.dataclass()
class PRLabelerCtx(LabelerCtx):
pr: github.PullRequest.PullRequest
TYPE = "pull request"
@property
def member(self) -> IssueOrPr:
return self.pr
@property
def event_member(self) -> dict[str, Any]:
return self.event_info.get("pull_request", {})

View File

@@ -0,0 +1,22 @@
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Constants for the tagging script
"""
from __future__ import annotations
from pathlib import Path
from codeowners import 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.parent
CODEOWNERS = (ROOT / ".github/CODEOWNERS").read_text("utf-8")
NEW_CONTRIBUTOR_LABEL = "new_contributor"

View File

@@ -0,0 +1,8 @@
The following files are automatically generated and should not be modified outside of the Ansible release process:
{% for file in changed_files %}
- {{ file }}
{% endfor %}
Please double-check your changes.
<!--- boilerplate: porting_guide_changes --->

View File

@@ -0,0 +1,117 @@
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Utilities for working with the Github API
"""
from __future__ import annotations
import json
import os
from contextlib import suppress
from typing import TYPE_CHECKING, Any
import github
import github.Auth
import github.Issue
import github.PullRequest
import github.Repository
from .cli_context import IssueLabelerCtx, IssueOrPrCtx
from .utils import log
if TYPE_CHECKING:
from typing_extensions import TypeAlias
IssueOrPr: TypeAlias = "github.Issue.Issue | github.PullRequest.PullRequest"
def get_repo(
full_repo: str,
authed: bool = True,
) -> tuple[github.Github, github.Repository.Repository]:
"""
Create a Github client and return a `github.Repository.Repository` object
Args:
full_repo: OWNER/NAME of the repository
authed:
Whether to create an authenticated Github client with the
`$GITHUB_TOKEN` environment variable as the key
"""
gclient = github.Github(
auth=github.Auth.Token(os.environ["GITHUB_TOKEN"]) if authed else None,
)
repo_obj = gclient.get_repo(full_repo)
return gclient, repo_obj
def get_event_info() -> dict[str, Any]:
"""
Load Github event JSON data from `$event_data`
"""
event_json = os.environ.get("event_json")
if not event_json:
return {}
with suppress(json.JSONDecodeError):
return json.loads(event_json)
return {}
# Operations
def get_team_members(ctx: IssueOrPrCtx, team: str) -> list[str]:
"""
Get the members of a Github team
"""
return [
user.login
for user in ctx.client.get_organization(ctx.repo.organization.login)
.get_team_by_slug(team)
.get_members()
]
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 is_new_contributor_assoc(ctx: IssueOrPrCtx) -> bool:
"""
Determine whether a user has previously contributed.
Requires authentication as a regular user and does not work with an app
token.
"""
author_association = ctx.event_member.get(
"author_association", ctx.member.raw_data["author_association"]
)
log(ctx, "author_association is", author_association)
return author_association in {"FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR"}
def is_new_contributor_manual(ctx: IssueOrPrCtx) -> bool:
"""
Determine whether a user has previously opened an issue or PR in this repo
without needing special API access.
"""
query_data = {
"repo": "ansible/ansible-documentation",
"author": ctx.issue.user.login,
# Avoid potential race condition where a new contributor opens multiple
# PRs or issues at once.
# Better to welcome twice than not at all.
"is": "closed",
}
issues = ctx.client.search_issues("", **query_data)
for issue in issues:
if issue.number != ctx.issue.number:
return False
return True

View File

@@ -0,0 +1,26 @@
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Utilities for Jinja2 templating
"""
from __future__ import annotations
from typing import Any, cast
from jinja2 import Environment, PackageLoader, StrictUndefined, select_autoescape
JINJA2_ENV = Environment(
loader=PackageLoader(cast(str, __package__), "data"),
autoescape=select_autoescape(),
trim_blocks=True,
undefined=StrictUndefined,
)
def get_data_file(name: str, **kwargs: Any) -> str:
"""
Template a data file
"""
return JINJA2_ENV.get_template(name).render(**kwargs).rstrip("\n")

View File

View File

@@ -0,0 +1,19 @@
# Copyright (C) 2023 Maxwell G <maxwell@gtmx.me>
# SPDX-License-Identifier: GPL-3.0-or-later
"""
Generic utilities
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .cli_context import IssueOrPrCtx
# 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)

View File

@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools"]
backend = "setuptools.build_meta"
[project]
name = "ad-internal-pr-labeler"
description = "Internal package to triage ansible-documentation issues and PRs"
classifiers = [
# Internal package
"Private :: Do Not Upload",
]
version = "0"
requires-python = ">=3.9"
dynamic = ["dependencies"]
[project.scripts]
ad-triage = "pr_labeler.cli:APP"
[tool.setuptools.dynamic]
dependencies = {file = "requirements.txt"}
[tool.setuptools.packages.find]
where = ["."]
include = ["pr_labeler*"]
[tool.uv]
cache-keys = [{ file = "requirements.txt" }]

View File

@@ -1,4 +1,4 @@
codeowners
jinja2
pygithub
typer
typer-slim

View File

@@ -10,7 +10,7 @@ from typing import cast
import nox
LINT_FILES: tuple[str, ...] = (
"hacking/pr_labeler/label.py",
"hacking/pr_labeler/pr_labeler",
"noxfile.py",
*iglob("docs/bin/*.py"),
)

1
tests/pr_labeler.in Symbolic link
View File

@@ -0,0 +1 @@
../hacking/pr_labeler/requirements.txt

51
tests/pr_labeler.txt Normal file
View File

@@ -0,0 +1,51 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --allow-unsafe --output-file=tests/pr_labeler.txt --strip-extras tests/pr_labeler.in
#
certifi==2024.8.30
# via requests
cffi==1.17.1
# via
# cryptography
# pynacl
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via typer-slim
codeowners==0.7.0
# via -r tests/pr_labeler.in
cryptography==43.0.1
# via pyjwt
deprecated==1.2.14
# via pygithub
idna==3.8
# via requests
jinja2==3.1.4
# via -r tests/pr_labeler.in
markupsafe==2.1.5
# via jinja2
pycparser==2.22
# via cffi
pygithub==2.4.0
# via -r tests/pr_labeler.in
pyjwt==2.9.0
# via pygithub
pynacl==1.5.0
# via pygithub
requests==2.32.3
# via pygithub
typer-slim==0.12.5
# via -r tests/pr_labeler.in
typing-extensions==4.12.2
# via
# codeowners
# pygithub
# typer-slim
urllib3==2.2.3
# via
# pygithub
# requests
wrapt==1.16.0
# via deprecated

View File

@@ -15,7 +15,7 @@ cffi==1.17.1
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via typer
# via typer-slim
codeowners==0.7.0
# via -r tests/../hacking/pr_labeler/requirements.txt
colorlog==6.8.2
@@ -32,12 +32,8 @@ idna==3.9
# via requests
jinja2==3.1.4
# via -r tests/../hacking/pr_labeler/requirements.txt
markdown-it-py==3.0.0
# via rich
markupsafe==2.1.5
# via jinja2
mdurl==0.1.2
# via markdown-it-py
mypy==1.11.2
# via -r tests/typing.in
mypy-extensions==1.0.0
@@ -52,30 +48,24 @@ pycparser==2.22
# via cffi
pygithub==2.4.0
# via -r tests/../hacking/pr_labeler/requirements.txt
pygments==2.18.0
# via rich
pyjwt==2.9.0
# via pygithub
pynacl==1.5.0
# via pygithub
requests==2.32.3
# via pygithub
rich==13.8.1
# via typer
shellingham==1.5.4
# via typer
tomli==2.0.1
# via
# mypy
# nox
typer==0.12.5
typer-slim==0.12.5
# via -r tests/../hacking/pr_labeler/requirements.txt
typing-extensions==4.12.2
# via
# codeowners
# mypy
# pygithub
# typer
# typer-slim
urllib3==2.2.3
# via
# pygithub