repomatic.github.issue module

GitHub issue lifecycle management.

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

Conversation locking covers both kinds rather than issues alone, because GitHub gives issues and pull requests one number space and one lock endpoint: 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

repomatic.github.issue.BOT_ISSUE_LABEL = '🤖 ci'

Label carried by every issue this module’s lifecycle helper maintains.

Applied by 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.

repomatic.github.issue.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.

repomatic.github.issue.LOCK_INACTIVE_DAYS = 90

Days a closed thread must sit untouched before 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.

repomatic.github.issue.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.

repomatic.github.issue.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.

repomatic.github.issue.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.

repomatic.github.issue.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 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.

repomatic.github.issue.LOCK_SEARCH_LIMIT = 200

Threads examined per 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.

repomatic.github.issue.add_labels(repository, number, labels, *, assignees=(), is_pr=False)[source]

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.

Parameters:
  • repository (str | None) – GitHub repository in owner/name form, or None to let gh resolve it from the working directory’s remote.

  • number (int) – The issue or pull request number.

  • labels (Sequence[str]) – Labels to add.

  • assignees (Sequence[str]) – Accounts to assign. A no-op when both this and labels are empty.

  • is_pr (bool) – Whether number names a pull request rather than an issue.

Return type:

bool

Returns:

Whether the attributes were applied. False on an API failure, which is logged rather than raised, per the refusals above.

repomatic.github.issue.search_stale_threads(repository, inactive_days=90, limit=200)[source]

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.

Parameters:
  • repository (str) – GitHub repository in owner/name form.

  • inactive_days (int) – Days without an update before a closed thread qualifies.

  • limit (int) – Maximum number of threads to return.

Return type:

list[dict[str, Any]]

Returns:

Search result dicts carrying number, title, url, isPullRequest, labels and updatedAt, newest first.

repomatic.github.issue.lock_thread(repository, number, *, is_pr, comment='', reason='resolved')[source]

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.

Parameters:
  • repository (str) – GitHub repository in owner/name form.

  • number (int) – The issue or pull request number to lock.

  • is_pr (bool) – Whether number names a pull request rather than an issue.

  • comment (str) – Comment to post before locking. Skipped when empty.

  • reason (str) – Lock reason, one of GitHub’s four accepted values. Omitted from the call when empty.

Return type:

None

repomatic.github.issue.lock_stale_threads(repository, inactive_days=90, 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.', 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.', exclude_labels=('🤖 ci',), limit=200, reason='resolved', *, dry_run=True)[source]

Lock every closed thread left inactive for inactive_days.

Caution

exclude_labels defaults to BOT_ISSUE_LABEL because the issues 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 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.

Parameters:
  • repository (str) – GitHub repository in owner/name form.

  • inactive_days (int) – Days without an update before a closed thread qualifies.

  • issue_comment (str) – Comment posted on an issue before locking it.

  • pr_comment (str) – Comment posted on a pull request before locking it.

  • exclude_labels (Sequence[str]) – Threads carrying any of these labels are left alone.

  • limit (int) – Maximum number of threads to examine in one run.

  • reason (str) – Lock reason passed to gh {issue,pr} lock --reason.

  • dry_run (bool) – Report what would be locked without writing anything.

Return type:

list[tuple[str, str, str, str]]

Returns:

One (kind, number, title, outcome) row per examined thread.

repomatic.github.issue.list_issues(title='')[source]

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 (triage_issues()) already matches by exact title, so author-agnostic listing is safe.

Parameters:

title (str) – If provided, only return issues whose title matches exactly.

Return type:

list[dict[str, Any]]

Returns:

List of issue dicts with number, title, createdAt, and state.

repomatic.github.issue.unlock_thread(number, *, is_pr=False)[source]

Unlock an issue or pull request’s conversation.

Parameters:
  • number (int) – The issue or pull request number to unlock.

  • is_pr (bool) – Whether number names a pull request rather than an issue.

Return type:

None

repomatic.github.issue.run_unlocking(args, number, *, is_pr=False)[source]

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 close_pr()’s whole retire path. Nothing downstream distinguishes either from a real failure, so the job dies and the report is never filed.

lock_stale_threads() no longer causes that, since it skips anything carrying 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.

Parameters:
  • args (Sequence[str]) – The gh command arguments to run.

  • number (int) – The thread number the command targets, used to unlock.

  • is_pr (bool) – Whether number names a pull request rather than an issue.

Return type:

str

Returns:

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.

repomatic.github.issue.close_issue(number, comment)[source]

Close an issue with a comment.

Parameters:
  • number (int) – The issue number to close.

  • comment (str) – The comment to add when closing.

Return type:

None

repomatic.github.issue.reopen_issue(number, comment='')[source]

Reopen a previously closed issue.

A closed issue old enough to reopen is old enough to have been autolocked, so the write goes through run_unlocking().

Parameters:
  • number (int) – The issue number to reopen.

  • comment (str) – Optional comment to add when reopening.

Return type:

None

repomatic.github.issue.create_issue(body_file, labels, title)[source]

Create a new issue.

Parameters:
  • body_file (Path) – Path to the file containing the issue body, already trimmed to GitHub’s size limit (see fit_github_body()).

  • labels (list[str]) – List of labels to apply.

  • title (str) – Issue title.

Return type:

int

Returns:

The created issue number.

Raises:

RuntimeError – When the output carries no parsable issue URL.

repomatic.github.issue.update_issue(number, body_file)[source]

Update an existing issue body.

Parameters:
  • number (int) – The issue number to update.

  • body_file (Path) – Path to the file containing the new issue body, already trimmed to GitHub’s size limit (see fit_github_body()).

Return type:

None

repomatic.github.issue.triage_issues(issues, title, needed)[source]

Triage issues matching a title for deduplication.

Parameters:
  • issues (list[dict]) – 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".

  • title (str) – Issue title to match against.

  • needed (bool) – Whether an issue with this title should exist.

Return type:

tuple[bool, int | None, str | None, set[int]]

Returns:

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

repomatic.github.issue.manage_issue_lifecycle(has_issues, body, labels, title, no_issues_comment='No more issues.')[source]

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 run_unlocking().

Parameters:
  • has_issues (bool) – Whether issues were found that warrant an open issue.

  • body (str) – 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.

  • labels (list[str]) – Labels to apply when creating a new issue.

  • title (str) – Issue title to match and create.

  • no_issues_comment (str) – Comment to add when closing issues because the condition no longer applies.

Return type:

None