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
issueapplies it on creation, andlock_stale_threads()spares it by default, so it lives at this lower layer. The value is one of the labelsrepomatic/data/labels.tomldeclares, 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 --reasonwants.
- 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-threadstable.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-bychoices 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:
StrEnumThe two kinds of conversation thread, valued as
ghnames its commands.- ISSUE = 'issue'¶
- PR = 'pr'¶
- repomatic.github.threads.repo_args(repository)[source]¶
The
--repoarguments naming repository, none whenghresolves it.
- 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.actoris the natural assignee and isgithub-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.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 inowner/nameform, orNoneto letghresolve it.
- Return type:
- Returns:
Whether the attributes were applied.
Falseon 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-prsquery covers both andisPullRequestsorts them afterwards. Halving the round-trips matters less than the ordering it buys: results come back newest-first across both kinds, so alimitthat truncates cuts the same slice from either.Note
The
is:unlockedhalf 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-threadlockedre-check before writing.- Parameters:
- Return type:
- Returns:
Search result dicts carrying
number,title,url,isPullRequest,labelsandupdatedAt, 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 ofLOCK_REASONS. Omitted from the call when empty.repository (
str|None) – Repository inowner/nameform, orNoneto letghresolve it.
- Return type:
- 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_labelsdefaults toBOT_ISSUE_LABELbecause the issuesmanage_issue_lifecycle()maintains are designed to be reopened when their condition recurs, and GitHub refusesaddCommenton a locked conversation. Locking one turns the next reopen into a failed job, which is the holerun_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, whichgh searchparses 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 inowner/nameform.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 togh {issue,pr} lock --reason.dry_run (
bool) – Report what would be locked without writing anything.
- Return type:
- 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 inowner/nameform, orNoneto letghresolve it.
- Return type:
- repomatic.github.threads.run_unlocking(args, number, *, kind=ThreadKind.ISSUE, repository=None)[source]¶
Run a commenting
ghcommand, clearing a conversation lock if it blocks.GitHub refuses
addCommenton a locked conversation, which is how a lock breaks the recurring issuesissuemanages: the next run that needs to reopen one has its reopen comment rejected. The same refusal breaksgh pr close --commenton a locked pull request, which isclose_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 carryingBOT_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 --jsonandgh issue view --jsonboth omit thelockedfield, so a pre-flight check would need a REST round-trip on every run to buy nothing.- Parameters:
args (
Sequence[str]) – Theghcommand 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 inowner/nameform, orNoneto letghresolve it. The command and the unlock both target it.
- Return type:
- 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.