Source code for repomatic.github.issue

# 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.

"""GitHub issue lifecycle management.

Generic primitives for listing, creating, updating, closing, triaging and
locking GitHub issues via the `gh` CLI, used by {mod}`repomatic.broken_links`
and other modules that manage bot-created issues. The pull-request counterpart
lives in {mod}`~repomatic.github.pr`.

Conversation locking covers both kinds rather than issues alone, because
GitHub gives issues and pull requests one number space and one lock endpoint:
{func}`lock_stale_threads` backs the `lock-threads` command and the autolock
workflow behind it.

The life-cycle of issues created in CI jobs is managed here by hand because the
`create-issue-from-file` action blindly creates issues ad-nauseam.

See:
- https://github.com/peter-evans/create-issue-from-file/issues/298
- https://github.com/lycheeverse/lychee-action/issues/74#issuecomment-1587089689
"""

from __future__ import annotations

import json
import logging
from datetime import datetime, timedelta, timezone
from operator import itemgetter
from pathlib import Path

from ..metadata import Metadata
from .gh import parse_create_output, run_gh_command
from .pr_body import (
    fit_github_body,
    generate_pr_metadata_block,
    temp_body_file,
)

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


BOT_ISSUE_LABEL = "πŸ€– ci"
"""Label carried by every issue this module's lifecycle helper maintains.

Applied by {func}`~repomatic.github.issue.manage_issue_lifecycle` on creation.
Lives here rather than in a calling module because both callers reach the label
through that helper, and neither should have to import the other to agree on
it. 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. That is the same
measure `dessant/lock-threads` used, at the same 90-day value this repository
configured it with, so replacing the action changed no thread's fate.
"""

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_REASON = "resolved"
"""Reason attached to every automated lock.

One of the four values GitHub accepts (`off_topic`, `resolved`, `spam`,
`too_heated`), spelled the way `gh issue lock --reason` wants it. `resolved` is
what `dessant/lock-threads` defaulted to, and it is the only one of the four
that describes a thread locked for age rather than for conduct.
"""

LOCK_THREADS_HEADER_DEFS: tuple[tuple[str, str], ...] = (
    ("Kind", "kind"),
    ("Thread", "thread"),
    ("Title", "title"),
    ("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 blast radius 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] def add_labels( repository: str, number: int, labels: Sequence[str], *, is_pr: bool = False, ) -> bool: """Add labels 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. :param repository: GitHub repository in `owner/name` form. :param number: The issue or pull request number. :param labels: Labels to add. A no-op when empty. :param is_pr: Whether *number* names a pull request rather than an issue. :return: Whether the labels were applied. `False` on an API failure, which is logged rather than raised: a labelling run is a convenience, and failing the job over it would be louder than the outcome deserves. """ if not labels: return True resource = "pr" if is_pr else "issue" args = [resource, "edit", str(number), "--repo", repository] for label in labels: args.extend(["--add-label", label]) try: run_gh_command(args) except RuntimeError: logging.error(f"Failed to label {resource} #{number} in {repository}") return False logging.info( f"Added {', '.join(map(repr, labels))} to {resource} #{number} in {repository}" ) 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: GitHub repository in `owner/name` form. :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(timezone.utc).date() - timedelta(days=inactive_days) output = run_gh_command([ "search", "issues", "--repo", repository, "--state", "closed", "--locked=false", "--include-prs", "--updated", f"<{cutoff.isoformat()}", "--sort", "updated", "--limit", str(limit), "--json", "number,title,url,isPullRequest,labels,updatedAt", ]) threads: list[dict[str, Any]] = json.loads(output) 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( repository: str, number: int, *, is_pr: bool, comment: str = "", reason: str = LOCK_REASON, ) -> 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 repository: GitHub repository in `owner/name` form. :param number: The issue or pull request number to lock. :param is_pr: Whether *number* names a pull request rather than an issue. :param comment: Comment to post before locking. Skipped when empty. :param reason: Lock reason, one of GitHub's four accepted values. Omitted from the call when empty. """ kind = "pr" if is_pr else "issue" if comment: run_gh_command([ kind, "comment", str(number), "--repo", repository, "--body", comment, ]) args = [kind, "lock", str(number), "--repo", repository] if reason: args.extend(["--reason", reason]) run_gh_command(args) logging.info(f"Locked {kind} #{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}`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` exists to patch 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: GitHub 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): is_pr = bool(thread["isPullRequest"]) number = int(thread["number"]) kind = "PR" if is_pr else "issue" labels = {label["name"].casefold() for label in thread.get("labels", ())} if held := sorted(labels & excluded): logging.debug(f"Skipping {kind} #{number}, labelled {held}.") rows.append((kind, f"#{number}", thread["title"], "skipped (labelled)")) continue if dry_run: rows.append((kind, f"#{number}", thread["title"], "would lock")) continue lock_thread( repository, number, is_pr=is_pr, comment=pr_comment if is_pr else issue_comment, reason=reason, ) rows.append((kind, f"#{number}", thread["title"], "locked")) return rows
[docs] def list_issues(title: str = "") -> list[dict[str, Any]]: """List all issues (open and closed), optionally filtered by title. ```{note} No `--author` filter is applied. When `REPOMATIC_PAT` is configured, `gh` authenticates as the token owner (not `github-actions[bot]`), so issues may be authored by either identity. Filtering by author would miss issues created under the other identity, breaking deduplication. The caller ({func}`triage_issues`) already matches by exact title, so author-agnostic listing is safe. ``` :param title: If provided, only return issues whose title matches exactly. :return: List of issue dicts with `number`, `title`, `createdAt`, and `state`. """ args = [ "issue", "list", "--state", "all", "--json", "number,title,createdAt,state", ] if title: args.extend(["--search", f"{title} in:title"]) output = run_gh_command(args) issues: list[dict[str, Any]] = json.loads(output) # `--search` is full-text, not exact match. Filter to exact title. if title: issues = [i for i in issues if i["title"] == title] return issues
[docs] def unlock_thread(number: int, *, is_pr: bool = False) -> None: """Unlock an issue or pull request's conversation. :param number: The issue or pull request number to unlock. :param is_pr: Whether *number* names a pull request rather than an issue. """ kind = "pr" if is_pr else "issue" run_gh_command([ kind, "unlock", str(number), ]) logging.info(f"Unlocked {kind} #{number}")
[docs] def run_unlocking(args: Sequence[str], number: int, *, is_pr: bool = False) -> 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 this module manages: the next run that needs to reopen one (because the condition recurred) 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 distinguishes either from a real failure, so the job dies and the report is never filed. {func}`lock_stale_threads` no longer causes that, since it skips anything carrying {data}`BOT_ISSUE_LABEL`. This path remains for the locks it does not own: one applied by hand, or one left behind by the `dessant/lock-threads` action this command replaced, which had no such exclusion configured. 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. :param number: The thread number the command targets, used to unlock. :param is_pr: Whether *number* names a pull request rather than an issue. :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. """ try: return run_gh_command(list(args)) except RuntimeError as error: if LOCKED_CONVERSATION_MARKER not in str(error): raise kind = "PR" if is_pr else "issue" logging.warning( f"{kind} #{number} conversation is locked, unlocking to write to it." ) unlock_thread(number, is_pr=is_pr) return run_gh_command(list(args))
[docs] def close_issue(number: int, comment: str) -> None: """Close an issue with a comment. :param number: The issue number to close. :param comment: The comment to add when closing. """ run_unlocking( [ "issue", "close", str(number), "--comment", comment, ], number, ) logging.info(f"Closed issue #{number}")
[docs] def reopen_issue(number: int, comment: str = "") -> None: """Reopen a previously closed issue. A closed issue old enough to reopen is old enough to have been autolocked, so the write goes through {func}`run_unlocking`. :param number: The issue number to reopen. :param comment: Optional comment to add when reopening. """ args = [ "issue", "reopen", str(number), ] if comment: args.extend(["--comment", comment]) run_unlocking(args, number) logging.info(f"Reopened issue #{number}")
[docs] def create_issue(body_file: Path, labels: list[str], title: str) -> int: """Create a new issue. :param body_file: Path to the file containing the issue body, already trimmed to GitHub's size limit (see {func}`~repomatic.github.pr_body.fit_github_body`). :param labels: List of labels to apply. :param title: Issue title. :return: The created issue number. :raises RuntimeError: When the output carries no parsable issue URL. """ args = [ "issue", "create", "--title", title, "--body-file", str(body_file), ] for label in labels: args.extend(["--label", label]) output = run_gh_command(args) issue_number, _url = parse_create_output(output, "issue") logging.info(f"Created issue #{issue_number}") return issue_number
[docs] def update_issue(number: int, body_file: Path) -> None: """Update an existing issue body. :param number: The issue number to update. :param body_file: Path to the file containing the new issue body, already trimmed to GitHub's size limit (see {func}`~repomatic.github.pr_body.fit_github_body`). """ run_gh_command([ "issue", "edit", str(number), "--body-file", str(body_file), ]) logging.info(f"Updated issue #{number}")
[docs] def triage_issues( issues: list[dict], title: str, needed: bool, ) -> tuple[bool, int | None, str | None, set[int]]: """Triage issues matching a title for deduplication. :param issues: List of issue dicts from `gh issue list --json number,title,createdAt,state`. The `state` field is optional for backward compatibility; when absent it defaults to `"OPEN"`. :param title: Issue title to match against. :param needed: Whether an issue with this title should exist. :return: A tuple of `(issue_needed, issue_to_update, issue_state, issues_to_close)`. If `needed` is `True`, the most recent matching issue is kept as `issue_to_update` (with its `issue_state`) and all older matching issues are collected in `issues_to_close`. If `needed` is `False`, all open matching issues are placed in `issues_to_close` (already-closed issues are skipped). """ issue_to_update: int | None = None issue_state: str | None = None issues_to_close: set[int] = set() for issue in sorted(issues, key=itemgetter("createdAt"), reverse=True): logging.debug(f"Processing {issue!r} ...") if issue["title"] != title: logging.debug(f"{issue!r} does not match title, skip.") continue state = issue.get("state", "OPEN") if needed and issue_to_update is None: logging.debug(f"{issue!r} is the most recent matching issue.") issue_to_update = issue["number"] issue_state = state else: # Only close open issues; skip already-closed ones. if state == "OPEN": logging.debug(f"{issue!r} is a duplicate to close.") issues_to_close.add(issue["number"]) else: logging.debug(f"{issue!r} is already closed, skip.") return needed, issue_to_update, issue_state, issues_to_close
[docs] def manage_issue_lifecycle( has_issues: bool, body: str, labels: list[str], title: str, no_issues_comment: str = "No more issues.", ) -> None: """Manage the full issue lifecycle: list, triage, close, create/update. This function handles: 1. Listing all issues (open and closed) via `gh issue list`. 2. Triaging matching issues (keep newest if needed, close duplicates). 3. Closing duplicate open issues via `gh issue close`. 4. Creating, updating, or reopening the main issue via `gh issue create`, `gh issue edit`, or `gh issue reopen`. When `has_issues` is `True` and the most recent matching issue is closed, it is reopened and updated rather than creating a duplicate. A conversation lock standing in the way of that reopen is cleared first, so a recurring issue survives the autolock workflow that closes over it; see {func}`run_unlocking`. :param has_issues: Whether issues were found that warrant an open issue. :param body: The rendered markdown issue body. Written to a temporary file only when a create or update actually happens, since a run that just closes issues never needs one. :param labels: Labels to apply when creating a new issue. :param title: Issue title to match and create. :param no_issues_comment: Comment to add when closing issues because the condition no longer applies. """ # List all issues (open and closed) matching this title. issues = list_issues(title) logging.info(f"Found {len(issues)} issues matching {title!r}") # Triage issues. _, issue_to_update, issue_state, issues_to_close = triage_issues( issues, title, has_issues, ) # Generate workflow metadata block for issue comments. metadata_block = generate_pr_metadata_block(Metadata()) # Close duplicate/obsolete open issues. for issue_number in issues_to_close: if issue_to_update: comment = f"Superseded by #{issue_to_update}." else: comment = no_issues_comment close_issue(issue_number, f"{comment}\n\n{metadata_block}") if not has_issues: return # Create, update, or reopen the issue. The body is fitted to GitHub's # size limit before it ever touches disk, so the write paths below need # no rewrite-in-place dance. fitted = fit_github_body(body) if fitted != body: logging.warning("Issue body exceeds GitHub's size limit, trimming.") with temp_body_file(fitted) as body_path: if issue_to_update: # Reopen the issue if it was closed. if issue_state == "CLOSED": reopen_issue( issue_to_update, comment=f"Condition recurred.\n\n{metadata_block}", ) update_issue(issue_to_update, body_path) else: create_issue(body_path, labels, title=title)