Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ Some settings have no CLI flag and use environment variables:
| `G2G_LOG_LEVEL` | `WARNING` | Logging level; set `DEBUG` for verbose output |
| `G2G_TOPIC_PREFIX` | `GH` | Prefix used when generating Gerrit topics |
| `G2G_SKIP_GERRIT_COMMENTS` | `false` | Skip posting back-reference comments on Gerrit changes |
| `G2G_TRUSTED_ASSOCIATIONS` | `OWNER,MEMBER,COLLABORATOR` | Author associations trusted to issue `@github2gerrit` comment directives |
| `G2G_ENABLE_DERIVATION` | `true` | Enable automatic derivation of Gerrit parameters from organization defaults |
| `G2G_AUTO_SAVE_CONFIG` | context-dependent | Save derived parameters back to the configuration file |
| `G2G_RESPECT_USER_SSH` | `false` (`true` in direct URL mode) | Use the local user's SSH configuration and keys instead of provided key material |
Expand Down
51 changes: 51 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,57 @@ acts on it during the next workflow run.
- When the same command appears in more than one comment, only the latest
occurrence takes effect.
- The tool logs unrecognized directives at debug level and ignores them.
- Only comments from trusted authors count. See below.

### Who May Issue Commands

These repositories are public, so the tool checks who wrote a comment before
acting on it. Without that check, any GitHub user able to leave a comment
could direct the tool.

A comment counts only when GitHub reports its author's `author_association`
as one of:

| Association | Meaning |
| -------------- | ------------------------------------------ |
| `OWNER` | Owns the repository |
| `MEMBER` | Member of the owning organization |
| `COLLABORATOR` | Invited collaborator on the repository |

`CONTRIBUTOR` is **not** trusted. It means only that the author has had a
pull request merged at some point, which any outside contributor can achieve
and which carries no authority.

The tool ignores directives from anyone else and logs a warning naming the
author and their association, so a refused command is visible rather than
silently dropped.

#### For contributors adding commands

Adding a `CommandDefinition` to the registry gives the new command the same
authorisation as the existing one, so long as consumers reach it through
`github2gerrit.pr_directives`, which fetches comments, discards untrusted
authors and parses in a single step.

The parsing module performs no authorisation of its own. Calling
`parse_commands`, `find_command` or `has_command` with comments taken straight
from the GitHub API skips the check and recreates the original defect.

Override the trusted set with `G2G_TRUSTED_ASSOCIATIONS`, a comma-separated
list. Setting it to `OWNER` alone is the strictest useful value:

```yaml
env:
G2G_TRUSTED_ASSOCIATIONS: "OWNER,MEMBER"
```

An empty or blank value keeps the default rather than trusting nobody or
everyone.

Note that `MEMBER` means organization member, which is not the same as write
access to a given repository. On a Gerrit mirror that remains the only
available signal, because the GitHub collaborator list holds infrastructure
accounts rather than the project's reviewers.

### Available Commands

Expand Down
1 change: 1 addition & 0 deletions src/github2gerrit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"G2G_NO_GERRIT",
"G2G_DISABLED",
"G2G_TOPIC_PREFIX",
"G2G_TRUSTED_ASSOCIATIONS",
"G2G_LOG_LEVEL",
"G2G_SHOW_PROGRESS",
"G2G_RESPECT_USER_SSH",
Expand Down
16 changes: 9 additions & 7 deletions src/github2gerrit/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1655,7 +1655,12 @@ def _should_create_missing(

Returns ``True`` when either the ``--create-missing`` CLI flag
is active **or** a ``@github2gerrit create missing change``
comment is present on the PR.
comment is present on the PR *from a trusted author*.

Comment authorship is checked because these mirrors are public:
without it, anyone able to comment could force creation of a
Gerrit change that the UPDATE path deliberately declined to
make.
"""
# 1. Explicit CLI / environment flag
if inputs.create_missing:
Expand All @@ -1670,16 +1675,13 @@ def _should_create_missing(

try:
from .pr_commands import CMD_CREATE_MISSING
from .pr_commands import find_command
from .pr_directives import find_pr_command

client_gh = build_client()
repo = get_repo_from_env(client_gh)
pr_obj = get_pull(repo, int(gh.pr_number))

issue = pr_obj.as_issue()
comment_bodies = [c.body or "" for c in issue.get_comments()]

match = find_command(comment_bodies, CMD_CREATE_MISSING.name)
match = find_pr_command(pr_obj, CMD_CREATE_MISSING.name)
if match is not None:
log.info(
"✅ Found '@github2gerrit %s' in PR #%s comment #%d; "
Expand All @@ -1692,7 +1694,7 @@ def _should_create_missing(

log.debug(
"No @github2gerrit create-missing command found in "
"PR #%s comments",
"PR #%s comments from trusted authors",
gh.pr_number,
)
except Exception as exc:
Expand Down
60 changes: 60 additions & 0 deletions src/github2gerrit/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import logging
import os
import re
from collections.abc import Callable
from collections.abc import Iterable
from importlib import import_module
from typing import Any
Expand All @@ -30,6 +31,7 @@
from .error_codes import is_github_api_permission_error
from .external_api import ApiType
from .external_api import external_api_call
from .trust import is_trusted_association


# Error message constants to comply with TRY003
Expand Down Expand Up @@ -74,8 +76,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError(_MSG_PYGITHUB_REQUIRED)


class GhUser(Protocol):
login: str


class GhIssueComment(Protocol):
body: str | None
author_association: str | None
user: GhUser | None


class GhIssue(Protocol):
Expand Down Expand Up @@ -127,6 +135,7 @@ def get_repo(self, full: str) -> GhRepository:
"get_pull",
"get_recent_change_ids_from_comments",
"get_repo_from_env",
"get_trusted_comment_bodies",
"iter_open_pulls",
]

Expand Down Expand Up @@ -318,6 +327,57 @@ def get_recent_change_ids_from_comments(
return found


@external_api_call(ApiType.GITHUB, "get_trusted_comment_bodies")
def get_trusted_comment_bodies(
pr: GhPullRequest,
*,
directive_detector: Callable[[str], bool] | None = None,
Comment thread
ModeSevenIndustrialSolutions marked this conversation as resolved.
) -> tuple[list[str], list[str]]:
"""Split PR comments into trusted bodies and ignored directives.

Comments are only obeyed when their author's ``author_association``
is trusted (see :mod:`github2gerrit.trust`). Without this, anyone
able to comment on a public mirror could direct the tool.

Args:
pr: Pull request.
directive_detector: Predicate deciding whether an untrusted
comment was *attempting* to direct the tool, and so is worth
reporting. Supplied by the caller rather than matched here, so
that directive grammar stays owned by the command system and
cannot drift from it. When ``None``, nothing is reported.

Returns:
A ``(trusted_bodies, ignored)`` pair, ordered oldest to newest.
``ignored`` holds ``"login (ASSOCIATION)"`` descriptions for
untrusted comments that the detector accepted, so the caller can
explain the omission rather than failing silently.
"""
issue = _get_issue(pr)

trusted_bodies: list[str] = []
ignored: list[str] = []

for comment in issue.get_comments():
body = getattr(comment, "body", "") or ""
if not body:
continue

association = str(getattr(comment, "author_association", "") or "")
if is_trusted_association(association):
trusted_bodies.append(body)
continue

if directive_detector is not None and directive_detector(body):
login = (
getattr(getattr(comment, "user", None), "login", "")
or "unknown"
)
ignored.append(f"{login} ({association or 'unknown'})")

return trusted_bodies, ignored


@external_api_call(ApiType.GITHUB, "create_pr_comment")
def create_pr_comment(pr: GhPullRequest, body: str) -> None:
"""Create a new comment on the pull request."""
Expand Down
94 changes: 79 additions & 15 deletions src/github2gerrit/pr_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
Design principles
─────────────────
- **Registry-based**: new commands are added by appending a
``CommandDefinition`` to ``COMMAND_REGISTRY``; no other code changes
are required for recognition.
``CommandDefinition`` to ``COMMAND_REGISTRY``; no change to this
module's parsing logic is required.
- **Case-insensitive**: command matching ignores case so that
``@github2gerrit Create Missing Change`` works identically to
``@github2gerrit create missing change``.
Expand All @@ -25,6 +25,21 @@
- **Minimal coupling**: the module depends only on the standard library
and exposes typed dataclasses consumed by the orchestrator.

.. warning::

**This module performs no authorisation.** Every function here
assumes its input has already been filtered to comments from trusted
authors. Passing raw comments straight from the GitHub API lets any
user of a public mirror direct the tool — the defect fixed in issue
#382.

Adding a command to ``COMMAND_REGISTRY`` requires no changes here,
but it does **not** grant that command a security gate on its own.
Consume commands through :mod:`github2gerrit.pr_directives`, which
fetches, authorises and parses in one step, rather than calling
:func:`parse_commands`, :func:`find_command` or :func:`has_command`
directly.

Supported commands
──────────────────
``create missing change`` (aliases ``create missing``, ``create-missing``)
Expand All @@ -48,6 +63,7 @@
"CommandDefinition",
"CommandMatch",
"CommandParseResult",
"contains_directive",
"find_command",
"has_command",
"list_commands",
Expand Down Expand Up @@ -208,16 +224,52 @@ def _build_phrase_index() -> dict[str, str]:
# ── Public API ──────────────────────────────────────────────────────


def parse_commands(comment_bodies: list[str]) -> CommandParseResult:
"""Scan PR comment bodies for ``@github2gerrit`` commands.
def contains_directive(body: str) -> bool:
"""Report whether *body* holds a syntactically valid directive.

Applies the same grammar as :func:`parse_commands`, so a bare
``@github2gerrit`` mention is *not* a directive — consistent with
that function, which reports it as neither a match nor an
unrecognised directive.

A mention followed by whitespace alone is likewise not a directive.
The mention pattern's ``.`` matches spaces, so such text satisfies
the regex, but there is no command in it for anyone to act on.

Callers use this to distinguish an ordinary comment that happens to
mention the tool from one that attempts to direct it.

Args:
body: Comment body text.

Returns:
``True`` when the mention is followed by non-whitespace text.
"""
if not body:
return False
match = _MENTION_RE.search(body)
return match is not None and bool(match.group(1).strip())


def parse_commands(
trusted_comment_bodies: list[str],
) -> CommandParseResult:
"""Scan trusted PR comment bodies for ``@github2gerrit`` commands.

Comments are processed oldest-first. When the same command appears
in multiple comments the *latest* occurrence is kept (deduplication
by canonical command name).

.. warning::

Performs no authorisation. *trusted_comment_bodies* must
already exclude comments from untrusted authors; prefer
:func:`github2gerrit.pr_directives.scan_pr_directives`, which
does that for you.

Args:
comment_bodies: Ordered list of comment body strings
(oldest → newest).
trusted_comment_bodies: Ordered list of comment body strings
(oldest → newest), already filtered by author trust.

Returns:
A ``CommandParseResult`` containing de-duplicated matches and
Expand All @@ -228,7 +280,7 @@ def parse_commands(comment_bodies: list[str]) -> CommandParseResult:
seen: dict[str, CommandMatch] = {}
unrecognised: list[str] = []

for idx, body in enumerate(comment_bodies):
for idx, body in enumerate(trusted_comment_bodies):
if not body:
continue
for m in _MENTION_RE.finditer(body):
Expand Down Expand Up @@ -278,37 +330,49 @@ def parse_commands(comment_bodies: list[str]) -> CommandParseResult:
return result


def has_command(comment_bodies: list[str], command_name: str) -> bool:
"""Check whether a specific command exists in the PR comments.
def has_command(trusted_comment_bodies: list[str], command_name: str) -> bool:
"""Check whether a specific command exists in trusted PR comments.

This is a convenience wrapper around ``parse_commands`` for the
common case where only one command matters.

.. warning::

Performs no authorisation; see :func:`parse_commands`.

Args:
comment_bodies: Ordered list of comment body strings.
trusted_comment_bodies: Ordered list of comment body strings,
already filtered by author trust.
command_name: Canonical command name to check for.

Returns:
``True`` if the command was found in at least one comment.
"""
result = parse_commands(comment_bodies)
result = parse_commands(trusted_comment_bodies)
return result.has(command_name)


def find_command(
comment_bodies: list[str],
trusted_comment_bodies: list[str],
command_name: str,
) -> CommandMatch | None:
"""Find a specific command match in the PR comments.
"""Find a specific command match in trusted PR comments.

.. warning::

Performs no authorisation. Prefer
:func:`github2gerrit.pr_directives.find_pr_command`, which
filters by author trust first.

Args:
comment_bodies: Ordered list of comment body strings.
trusted_comment_bodies: Ordered list of comment body strings,
already filtered by author trust.
command_name: Canonical command name to search for.

Returns:
The ``CommandMatch`` if found, otherwise ``None``.
"""
result = parse_commands(comment_bodies)
result = parse_commands(trusted_comment_bodies)
target = command_name.lower().strip()
for m in result.matches:
if m.command_name == target:
Expand Down
Loading
Loading