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 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.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-threadsused, 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 waygh issue lock --reasonwants it.resolvedis whatdessant/lock-threadsdefaulted 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-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.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.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:
repository (
str|None) – GitHub repository inowner/nameform, orNoneto letghresolve it from the working directory’s remote.number (
int) – The issue or pull request number.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:
- Returns:
Whether the attributes were applied.
Falseon 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-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.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 inowner/nameform.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:
- 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_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()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, 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) – GitHub 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.issue.list_issues(title='')[source]¶
List all issues (open and closed), optionally filtered by title.
Note
No
--authorfilter is applied. WhenREPOMATIC_PATis configured,ghauthenticates as the token owner (notgithub-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.
- repomatic.github.issue.unlock_thread(number, *, is_pr=False)[source]¶
Unlock an issue or pull request’s conversation.
- repomatic.github.issue.run_unlocking(args, number, *, is_pr=False)[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 issues this module manages: the next run that needs to reopen one (because the condition recurred) 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 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 carryingBOT_ISSUE_LABEL. This path remains for the locks it does not own: one applied by hand, or one left behind by thedessant/lock-threadsaction 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 --jsonandgh issue view --jsonboth omit thelockedfield, so a pre-flight check would need a REST round-trip on every run to buy nothing.- Parameters:
- 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.
- 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().
- 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 (seefit_github_body()).title (
str) – Issue title.
- Return type:
- 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 (seefit_github_body()).
- Return type:
- repomatic.github.issue.triage_issues(issues, title, needed)[source]¶
Triage issues matching a title for deduplication.
- Parameters:
- Return type:
- Returns:
A tuple of (issue_needed, issue_to_update, issue_state, issues_to_close).
If
neededisTrue, the most recent matching issue is kept asissue_to_update(with itsissue_state) and all older matching issues are collected inissues_to_close. IfneededisFalse, all open matching issues are placed inissues_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:
Listing all issues (open and closed) via
gh issue list.Triaging matching issues (keep newest if needed, close duplicates).
Closing duplicate open issues via
gh issue close.Creating, updating, or reopening the main issue via
gh issue create,gh issue edit, orgh issue reopen.
When
has_issuesisTrueand 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; seerun_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: