repomatic.github.pr module

Pull-request lifecycle management for automated jobs.

upsert_pr() converges a bot branch and its pull request onto whatever the working tree currently holds: it opens the PR when there is something to say, refreshes it when the content moved, leaves it strictly alone when nothing changed, and retires branch and PR together once the change evaporates. Every sync-*, update-*, format-* and fix-* job funnels through it.

Note

Why this is not peter-evans/create-pull-request

This module ports the algorithm of that action: see docs/security.md for the third-party-action inventory it belongs to. Two properties make the port worth the code.

The action’s cleanup only fires when the action runs. Half these jobs sit behind an if: gate, and a skipped step cannot delete its own branch. A command that always runs and decides internally retires the branch on the same code path that opens it.

The pieces are already here: repomatic.git_ops owns the git side, gh the authenticated gh side, and pr_body renders title, body and commit message. Only the decision between them is new.

Note

What a job may hand this

Any mix of the two ways a job produces output: uncommitted working-tree edits (a formatter) and commits it made itself (a release freeze). Both survive, the second as separate commits. The checkout may sit on the base branch or be detached at a commit, as long as the base branch can be named, and the base may have moved on since: the carried commits are replayed onto its fresh tip. tests/test_workflows.py fails if a workflow reaches for peter-evans/create-pull-request again.

The one input that needs restating rather than inferring is the action’s add-paths, ported as add_paths. Every job upstream writes only what it means to publish, so the whole-tree default is right for all of them; a downstream job that provisions its own tooling into the checkout (an npm install of a linter, a package manager rewriting a lock file on the way past) has to name its output, or the provisioning rides along into the pull request.

repomatic.github.pr.STALE_PR_COMMENT = 'Closing automatically: this branch no longer differs from its base, so the change it carried has either landed or become moot.'

Comment left on a pull request retired by upsert_pr().

A retired pull request otherwise closes with no trace on the conversation of why it closed.

repomatic.github.pr.STALE_BUMP_PR_COMMENT = 'Closing stale {part} version-bump PR: `main` already advanced past the target version, so this branch no longer represents a valid bump.'

Comment left on a version-bump pull request close_stale_bump_prs() retires, with the version part to fill in.

repomatic.github.pr.TEMP_BRANCH_PREFIX = 'repomatic/pr-sync-'

Namespace for the throwaway local branch a sync builds its commit on.

Never pushed and deleted before the command returns. The candidate commit needs somewhere to live that is not the checked-out base branch, so that a base left untouched is what a failed run rolls back to.

repomatic.github.pr.AUTOMATION_PR_LIMIT = 100

How many open pull requests list_automation_prs() reads.

Wider than OPEN_PR_LIMIT, which reads one branch: the automation can hold one pull request per job open at once.

repomatic.github.pr.OPEN_PR_LIMIT = 10

Open pull requests list_open_prs_by_branch() reads at most.

A head branch carries one open pull request, so the bound only caps a listing gone wrong.

class repomatic.github.pr.PrOperation(*values)[source]

Bases: StrEnum

What a upsert_pr() call did, mirroring the action’s own vocabulary.

CREATED = 'created'

The branch was pushed and a new pull request opened.

UPDATED = 'updated'

The branch was force-pushed and the existing pull request refreshed.

CLOSED = 'closed'

The change evaporated: branch deleted and any open pull request closed.

NONE = 'none'

Nothing to do: the branch already carried exactly this change.

class repomatic.github.pr.PrSyncResult(operation, branch, number=None, url='')[source]

Bases: NamedTuple

Outcome of a upsert_pr() call.

Create new instance of PrSyncResult(operation, branch, number, url)

operation: PrOperation

Which of the four branches of the algorithm ran.

branch: str

The head branch the call targeted.

number: int | None

Pull-request number, when one was created, updated or closed.

url: str

Pull-request URL, populated only on creation.

repomatic.github.pr.list_open_prs_by_branch(branch)[source]

List open pull requests whose head branch matches branch.

Parameters:

branch (str) – The head branch name to filter on.

Return type:

list[dict[str, Any]]

Returns:

List of PR dicts with number and isDraft. Empty if no open PR exists on branch.

repomatic.github.pr.automation_branches()[source]

The head branches the bundled automation opens pull requests on.

A pr-sync job names its branch after its template, so the bundled templates that front a pull request list them, beside the version-bump and release branches. A template with no title fronts no pull request: an admonition, a footer, an issue body.

Return type:

frozenset[str]

repomatic.github.pr.behind_by(base, head)[source]

How many commits of base are missing from head.

Parameters:
  • base (str) – The branch to measure against.

  • head (str) – The branch measured.

Return type:

int | None

Returns:

The count, or None when the comparison cannot be read.

repomatic.github.pr.list_automation_prs()[source]

The open pull requests of the automation, with how far each lags its base.

An unattended sync-*, format-* or fix-* pull request is a pending write to the default branch nobody reviewed, and one that lags its base describes a tree the branch has since moved past.

Return type:

list[dict[str, Any]]

Returns:

One dict per pull request: the gh pr list fields number, headRefName, baseRefName, title, changedFiles, additions, deletions and url, plus behindBy.

repomatic.github.pr.release_pr(base)[source]

The open release pull request, with how far it lags base.

Parameters:

base (str) – The branch the release merges into.

Return type:

dict[str, Any] | None

Returns:

The gh pr view fields number, title, url, isDraft and headRefOid, plus behindBy, or None when no pull request is open on RELEASE_PR_BRANCH.

repomatic.github.pr.list_changed_files(number, repository='')[source]

List the repository-relative paths a pull request changes.

Reads the REST files endpoint, every page of it, rather than the diff, since a diff has to be transferred and parsed to recover names the API already hands over one field at a time.

Caution

GitHub caps this endpoint at 3,000 files, silently. A pull request past that returns a truncated list, so a file-glob rule keyed on the tail of a very large diff can miss. Nothing here can lift the cap, and the labeller this feeds is a first-pass convenience, so the truncation is tolerated rather than reported.

Parameters:
  • number (int) – The pull request number.

  • repository (str) – Repository in owner/name form. Left to gh’s own resolution when empty.

Return type:

list[str]

Returns:

Changed paths, in the order GitHub returns them.

repomatic.github.pr.close_pr(number, comment)[source]

Close a pull request with a comment, and delete its head branch.

The close comment is refused on a locked conversation, so the write goes through run_unlocking(): a hand-locked pull request would otherwise wedge upsert_pr()’s whole retire path.

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

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

Return type:

None

repomatic.github.pr.close_open_prs_on_branch(branch, comment)[source]

Close every open PR whose head branch matches branch.

Idempotent: a no-op when no open PR exists on the branch.

Parameters:
  • branch (str) – The head branch name to match.

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

Return type:

list[int]

Returns:

The list of PR numbers that were closed.

repomatic.github.pr.close_stale_bump_prs(part)[source]

Close the pull request of a version bump that is no longer allowed.

A scheduled bump that started before a competing push can open its pull request against a main that already advanced past the target, leaving an orphan that later runs cannot refresh. The caller decides the bump is no longer allowed, then this deletes the branch with its pull request.

Idempotent: a no-op when no open pull request exists on the branch.

Parameters:

part (str) – The version part whose bump branch to retire.

Return type:

list[int]

Returns:

The numbers of the pull requests closed.

repomatic.github.pr.carry_pr_branch_paths(branch, paths, remote='origin')[source]

Restore paths from an open pull request’s branch, before rebuilding them.

The counterpart of upsert_pr() for a job whose output accrues rather than converges. A convergent job rebuilds its file from scratch, so starting from the base branch loses nothing. An accruing one appends to what it wrote last time, and starting from the base would publish a branch holding one entry where the history needs all of them.

Reading the file back from the branch first makes each run append to what is already pending, so the pull request always shows the whole accrual as one diff against its base, however many runs went into it.

Only what accrues has to be carried. Anything the job derives from it is rebuilt from the restored file and lands on the same bytes the branch already holds, so passing a derived path here buys nothing.

Note

HEAD never moves: only the named files travel. A job carrying its store still runs the code and lock file of the branch it was called on, which a git checkout of the pull request branch would silently replace with whatever that branch was built from.

Idempotent and self-healing, whichever way the pull request was merged. Once merged, the branch’s copy and the base’s agree, so the restore changes nothing, upsert_pr() finds no diff, and it closes the pull request and deletes the branch. The next run starts a fresh accrual. A squash merge is no different, because every comparison here is of file content and never of commit history.

Parameters:
  • branch (str) – Remote branch holding the pending work, by convention the one the job’s own pull request is opened from.

  • paths (Sequence[str | Path]) – Files to restore. One the branch does not carry is skipped, which is every run that follows a merge.

  • remote (str) – Remote to read the branch from.

Return type:

tuple[str, ...]

Returns:

The paths actually restored, as repository-relative pathspecs.

repomatic.github.pr.upsert_pr(branch, title, body, commit_message, base=None, labels=(), assignees=(), draft=False, add_paths=(), remote='origin')[source]

Converge branch and its pull request onto what the checkout now holds.

Idempotent by construction: a second call over an unchanged checkout performs no write at all and reports PrOperation.NONE. The four outcomes are those of PrOperation.

The candidate branch is whatever this checkout has that the base does not: commits the job made itself are carried through as separate commits, and any uncommitted working-tree change becomes one more commit on top, so a job that commits (a release freeze) and a job that only edits files (a formatter) both work without saying which they are. The base commit is read once from the remote, so a detached HEAD is fine as long as the base branch can be named. When the base has moved past this checkout, the carried commits are replayed onto its fresh tip.

Parameters:
  • branch (str) – Head branch to create, update or retire.

  • title (str) – Pull-request title.

  • body (str) – Rendered markdown body, trimmed here if oversized.

  • commit_message (str) – Message for the commit capturing uncommitted changes. Unused when the job committed its own work.

  • base (str | None) – Base branch. Defaults to the checked-out branch, then, on a detached HEAD, to the repository default branch the CI event payload names.

  • labels (Sequence[str]) – Labels to attach, best-effort.

  • assignees (Sequence[str]) – Assignees to attach, best-effort.

  • draft (bool) – Hold the pull request in draft, on every update and not just at creation. See _mark_draft.

  • add_paths (Sequence[str]) – Git pathspecs limiting what the uncommitted-changes commit picks up. Empty commits the whole tree, which is right for a job whose only writes are the ones it means to publish. A job that also provisions its own tooling into the checkout needs the narrower form: anything outside the pathspec is left dirty and discarded with the throwaway branch.

  • remote (str) – Remote to publish to.

Raises:

RuntimeError – When no base branch can be named, or when the base does not exist on remote.

Return type:

PrSyncResult