Source code for repomatic.github.threads

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Write to an issue or a pull request: labels, locks, and lock recovery.

GitHub gives issues and pull requests one number space and one lock endpoint,
so the primitives here take a {class}`ThreadKind` instead of being written
twice. Each one takes the same `repository` keyword: `None` lets `gh` resolve
the repository from `GH_REPO` or the checkout.

{func}`lock_stale_threads` backs the `lock-threads` command and the autolock
workflow behind it.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime, timedelta
from enum import StrEnum

from click_extra import ColumnSpec

from .gh import gh_json, run_gh_command

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Sequence
    from typing import Any


BOT_ISSUE_LABEL = "🤖 ci"
"""Label carried by every issue repomatic maintains in CI.

The issue lifecycle of {mod}`~repomatic.github.issue` applies it on creation,
and {func}`lock_stale_threads` spares it by default, so it lives at this lower
layer. The value is one of the labels `repomatic/data/labels.toml` declares, so
renaming it there means renaming it here: an issue labelled with a name the
registry does not carry is created unlabelled, silently.
"""

LOCKED_CONVERSATION_MARKER = "is locked"
"""Substring GitHub returns when a write is refused on a locked conversation.

The full message is `GraphQL: Unable to create comment because issue is locked
(addComment)`. Matching the tail alone also covers the pull-request phrasing,
which names the other kind in the same slot.
"""

LOCK_INACTIVE_DAYS = 90
"""Days a closed thread must sit untouched before {func}`lock_stale_threads` locks it.

Counted from the thread's last update, not its closing date, so a closed issue
someone is still commenting on keeps resetting the clock.
"""

LOCK_ISSUE_COMMENT = (
    "This issue has been automatically locked since there has not been any"
    " recent activity after it was closed. Please open a new issue for related"
    " bugs."
)
"""Comment posted on an issue just before locking it."""

LOCK_PR_COMMENT = (
    "This pull request has been automatically locked since there has not been"
    " any recent activity after it was closed. Please open a new issue for"
    " related bugs."
)
"""Comment posted on a pull request just before locking it."""

LOCK_REASONS = ("off_topic", "resolved", "spam", "too_heated")
"""The lock reasons GitHub accepts, spelled the way `gh issue lock --reason` wants."""

LOCK_REASON = "resolved"
"""Reason attached to every automated lock.

One of {data}`LOCK_REASONS`, and the only one of them that describes a thread
locked for age rather than for conduct.
"""

LOCK_THREADS_HEADER_DEFS: tuple[ColumnSpec, ...] = (
    ColumnSpec("kind", "Kind"),
    ColumnSpec("thread", "Thread"),
    ColumnSpec("title", "Title"),
    ColumnSpec("outcome", "Outcome"),
)
"""Column definitions for the `repomatic lock-threads` table.

Lives beside {func}`lock_stale_threads`, whose rows it names, so the columns
and the tuple they render cannot drift apart; the CLI derives its `--sort-by`
choices from it.
"""

LOCK_SEARCH_LIMIT = 200
"""Threads examined per {func}`lock_stale_threads` run.

The search API caps a query at 1,000 results and the job runs weekly, so a
lower bound keeps one run's reach small while still draining a backlog over a
few weeks. A run that hits the cap says so, rather than reporting a clean sweep
of a set it only partially saw.
"""


[docs] class ThreadKind(StrEnum): """The two kinds of conversation thread, valued as `gh` names its commands.""" ISSUE = "issue" PR = "pr"
[docs] @classmethod def of(cls, is_pr: bool) -> ThreadKind: """The kind a `--pr/--issue` flag or an `isPullRequest` field names.""" return cls.PR if is_pr else cls.ISSUE
@property def label(self) -> str: """How a log line or a report names the kind: `issue` or `PR`.""" return "PR" if self is ThreadKind.PR else "issue"
[docs] def repo_args(repository: str | None) -> list[str]: """The `--repo` arguments naming *repository*, none when `gh` resolves it.""" return ["--repo", repository] if repository else []
[docs] def add_labels( number: int, labels: Sequence[str], *, kind: ThreadKind = ThreadKind.ISSUE, assignees: Sequence[str] = (), repository: str | None = None, ) -> bool: """Add labels, and optionally assignees, to an issue or pull request. Additive only: labels already on the thread are left in place, and none is ever removed. Every automated labeller here pre-labels for the maintainer's first pass, so it must never undo a classification made by hand. Deliberately non-fatal, because refusals are routine: `github.actor` is the natural assignee and is `github-actions[bot]` whenever the bot pushed the triggering commit, which GitHub refuses to assign, and a label renamed in the labels config but not yet synced is refused the same way. Neither is worth losing the thread over, so both degrade to a warning. :param number: The issue or pull request number. :param labels: Labels to add. :param kind: Whether *number* names an issue or a pull request. :param assignees: Accounts to assign. A no-op when both this and *labels* are empty. :param repository: Repository in `owner/name` form, or `None` to let `gh` resolve it. :return: Whether the attributes were applied. `False` on an API failure, which is logged rather than raised, per the refusals above. """ if not labels and not assignees: return True args = [kind, "edit", str(number), *repo_args(repository)] for label in labels: args.extend(["--add-label", label]) for assignee in assignees: args.extend(["--add-assignee", assignee]) try: run_gh_command(args) except RuntimeError as error: logging.warning( f"Could not set labels/assignees on {kind.label} #{number}: {error}" ) return False added = ", ".join(map(repr, (*labels, *assignees))) target = f" in {repository}" if repository else "" logging.info(f"Added {added} to {kind.label} #{number}{target}") return True
[docs] def search_stale_threads( repository: str, inactive_days: int = LOCK_INACTIVE_DAYS, limit: int = LOCK_SEARCH_LIMIT, ) -> list[dict[str, Any]]: """Search closed, unlocked issues and pull requests left inactive too long. Issues and pull requests share one number space and one search index, so a single `--include-prs` query covers both and `isPullRequest` sorts them afterwards. Halving the round-trips matters less than the ordering it buys: results come back newest-first across both kinds, so a `limit` that truncates cuts the same slice from either. ```{note} The `is:unlocked` half of the filter is what makes the whole operation idempotent, and it needs no state of its own: locking a thread removes it from this result set permanently. A second run minutes after the first therefore finds nothing, which is also why the search is authoritative enough to skip a per-thread `locked` re-check before writing. ``` :param repository: Repository in `owner/name` form. Required: a search without one covers every repository on GitHub. :param inactive_days: Days without an update before a closed thread qualifies. :param limit: Maximum number of threads to return. :return: Search result dicts carrying `number`, `title`, `url`, `isPullRequest`, `labels` and `updatedAt`, newest first. """ cutoff = datetime.now(UTC).date() - timedelta(days=inactive_days) threads: list[dict[str, Any]] = ( gh_json([ "search", "issues", *repo_args(repository), "--state", "closed", "--locked=false", "--include-prs", "--updated", f"<{cutoff.isoformat()}", "--sort", "updated", "--limit", str(limit), "--json", "number,title,url,isPullRequest,labels,updatedAt", ]) or [] ) logging.info( f"Found {len(threads)} closed thread(s) in {repository} with no activity" f" since {cutoff.isoformat()}." ) if len(threads) >= limit: logging.warning( f"Search returned the full {limit}-thread limit, so older threads may" " remain unlocked. They are picked up by the next run." ) return threads
[docs] def lock_thread( number: int, *, kind: ThreadKind, comment: str = "", reason: str = LOCK_REASON, repository: str | None = None, ) -> None: """Comment on a closed thread, then lock its conversation. The comment goes first on purpose: posting it after the lock would need the lock lifted again, and a reader arriving at a locked thread with no explanation has no way to learn where to go instead. :param number: The issue or pull request number to lock. :param kind: Whether *number* names an issue or a pull request. :param comment: Comment to post before locking. Skipped when empty. :param reason: Lock reason, one of {data}`LOCK_REASONS`. Omitted from the call when empty. :param repository: Repository in `owner/name` form, or `None` to let `gh` resolve it. """ target = repo_args(repository) if comment: run_gh_command([kind, "comment", str(number), *target, "--body", comment]) args = [kind, "lock", str(number), *target] if reason: args.extend(["--reason", reason]) run_gh_command(args) logging.info(f"Locked {kind.label} #{number}")
[docs] def lock_stale_threads( repository: str, inactive_days: int = LOCK_INACTIVE_DAYS, issue_comment: str = LOCK_ISSUE_COMMENT, pr_comment: str = LOCK_PR_COMMENT, exclude_labels: Sequence[str] = (BOT_ISSUE_LABEL,), limit: int = LOCK_SEARCH_LIMIT, reason: str = LOCK_REASON, *, dry_run: bool = True, ) -> list[tuple[str, str, str, str]]: """Lock every closed thread left inactive for *inactive_days*. ```{caution} `exclude_labels` defaults to {data}`BOT_ISSUE_LABEL` because the issues {func}`~repomatic.github.issue.manage_issue_lifecycle` maintains are *designed* to be reopened when their condition recurs, and GitHub refuses `addComment` on a locked conversation. Locking one turns the next reopen into a failed job, which is the hole {func}`run_unlocking` patches after the fact. Excluding the label stops the collision at the source; the recovery path stays in place for locks applied by hand. ``` Label exclusion is applied here rather than folded into the search query. GitHub's `-label:` qualifier would work, but it starts with a hyphen, which `gh search` parses as a flag and needs shell-level escaping to survive: a client-side filter over a field the search already returns costs one comparison and no quoting. :param repository: Repository in `owner/name` form. :param inactive_days: Days without an update before a closed thread qualifies. :param issue_comment: Comment posted on an issue before locking it. :param pr_comment: Comment posted on a pull request before locking it. :param exclude_labels: Threads carrying any of these labels are left alone. :param limit: Maximum number of threads to examine in one run. :param reason: Lock reason passed to `gh {issue,pr} lock --reason`. :param dry_run: Report what would be locked without writing anything. :return: One `(kind, number, title, outcome)` row per examined thread. """ excluded = {label.casefold() for label in exclude_labels} rows: list[tuple[str, str, str, str]] = [] for thread in search_stale_threads(repository, inactive_days, limit): kind = ThreadKind.of(bool(thread["isPullRequest"])) number = int(thread["number"]) labels = {label["name"].casefold() for label in thread.get("labels", ())} row = (kind.label, f"#{number}", thread["title"]) if held := sorted(labels & excluded): logging.debug(f"Skipping {kind.label} #{number}, labelled {held}.") rows.append((*row, "skipped (labelled)")) continue if dry_run: rows.append((*row, "would lock")) continue lock_thread( number, kind=kind, comment=pr_comment if kind is ThreadKind.PR else issue_comment, reason=reason, repository=repository, ) rows.append((*row, "locked")) return rows
[docs] def unlock_thread( number: int, *, kind: ThreadKind = ThreadKind.ISSUE, repository: str | None = None, ) -> None: """Unlock an issue or pull request's conversation. :param number: The issue or pull request number to unlock. :param kind: Whether *number* names an issue or a pull request. :param repository: Repository in `owner/name` form, or `None` to let `gh` resolve it. """ run_gh_command([kind, "unlock", str(number), *repo_args(repository)]) logging.info(f"Unlocked {kind.label} #{number}")
[docs] def run_unlocking( args: Sequence[str], number: int, *, kind: ThreadKind = ThreadKind.ISSUE, repository: str | None = None, ) -> str: """Run a commenting `gh` command, clearing a conversation lock if it blocks. GitHub refuses `addComment` on a locked conversation, which is how a lock breaks the recurring issues {mod}`~repomatic.github.issue` manages: the next run that needs to reopen one has its reopen comment rejected. The same refusal breaks `gh pr close --comment` on a locked pull request, which is {func}`~repomatic.github.pr.close_pr`'s whole retire path. Nothing downstream tells either from a real failure, so the job dies and the report is never filed. {func}`lock_stale_threads` skips anything carrying {data}`BOT_ISSUE_LABEL`, so this path covers the locks it does not own, like one applied by hand. Unlocking is deliberate rather than incidental. A conversation that repomatic is reopening is one it is about to comment on again, so the lock has outlived its purpose; autolock re-applies it 90 days after the thread next closes. ```{note} The lock is cleared only *after* a write actually fails, never speculatively. An unlocked conversation therefore costs no extra API call, and a lock set by hand on a thread repomatic never writes to is left alone. `gh issue list --json` and `gh issue view --json` both omit the `locked` field, so a pre-flight check would need a REST round-trip on every run to buy nothing. ``` :param args: The `gh` command arguments to run, without `--repo`. :param number: The thread number the command targets, used to unlock. :param kind: Whether *number* names an issue or a pull request. :param repository: Repository in `owner/name` form, or `None` to let `gh` resolve it. The command and the unlock both target it. :return: The command's standard output. :raises RuntimeError: When the command fails for any reason other than a conversation lock, or when it still fails after unlocking. """ command = [*args, *repo_args(repository)] try: return run_gh_command(command) except RuntimeError as error: if LOCKED_CONVERSATION_MARKER not in str(error): raise logging.warning( f"{kind.label} #{number} conversation is locked, unlocking it to" " write to it." ) unlock_thread(number, kind=kind, repository=repository) return run_gh_command(command)