repomatic.github.threads module

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

lock_stale_threads() backs the lock-threads command and the autolock workflow behind it.

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

Label carried by every issue repomatic maintains in CI.

The issue lifecycle of issue applies it on creation, and 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.

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

repomatic.github.threads.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.threads.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.threads.LOCK_REASONS = ('off_topic', 'resolved', 'spam', 'too_heated')

The lock reasons GitHub accepts, spelled the way gh issue lock --reason wants.

repomatic.github.threads.LOCK_REASON = 'resolved'

Reason attached to every automated lock.

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

repomatic.github.threads.LOCK_THREADS_HEADER_DEFS: tuple[ColumnSpec, ...] = (ColumnSpec(id='kind', label='Kind', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='thread', label='Thread', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='title', label='Title', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='outcome', label='Outcome', description='', max_width=None, optional=False, sortable=True))

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

class repomatic.github.threads.ThreadKind(*values)[source]

Bases: StrEnum

The two kinds of conversation thread, valued as gh names its commands.

ISSUE = 'issue'
PR = 'pr'
classmethod of(is_pr)[source]

The kind a --pr/--issue flag or an isPullRequest field names.

Return type:

ThreadKind

property label: str

How a log line or a report names the kind: issue or PR.

repomatic.github.threads.repo_args(repository)[source]

The --repo arguments naming repository, none when gh resolves it.

Return type:

list[str]

repomatic.github.threads.add_labels(number, labels, *, kind=ThreadKind.ISSUE, assignees=(), repository=None)[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:
  • number (int) – The issue or pull request number.

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

  • kind (ThreadKind) – Whether number names an issue or a pull request.

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

  • repository (str | None) – Repository in owner/name form, or None to let gh resolve it.

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.threads.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) – Repository in owner/name form. Required: a search without one covers every repository on GitHub.

  • 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.threads.lock_thread(number, *, kind, comment='', reason='resolved', repository=None)[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:
  • number (int) – The issue or pull request number to lock.

  • kind (ThreadKind) – Whether number names an issue or a pull request.

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

  • reason (str) – Lock reason, one of LOCK_REASONS. Omitted from the call when empty.

  • repository (str | None) – Repository in owner/name form, or None to let gh resolve it.

Return type:

None

repomatic.github.threads.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() 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.

Parameters:
  • repository (str) – 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.threads.unlock_thread(number, *, kind=ThreadKind.ISSUE, repository=None)[source]

Unlock an issue or pull request’s conversation.

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

  • kind (ThreadKind) – Whether number names an issue or a pull request.

  • repository (str | None) – Repository in owner/name form, or None to let gh resolve it.

Return type:

None

repomatic.github.threads.run_unlocking(args, number, *, kind=ThreadKind.ISSUE, repository=None)[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 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 close_pr()’s whole retire path. Nothing downstream tells either from a real failure, so the job dies and the report is never filed.

lock_stale_threads() skips anything carrying 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.

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

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

  • kind (ThreadKind) – Whether number names an issue or a pull request.

  • repository (str | None) – Repository in owner/name form, or None to let gh resolve it. The command and the unlock both target it.

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.