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:
StrEnumWhat 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:
NamedTupleOutcome 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.
- repomatic.github.pr.list_open_prs_by_branch(branch)[source]¶
List open pull requests whose head branch matches
branch.
- repomatic.github.pr.automation_branches()[source]¶
The head branches the bundled automation opens pull requests on.
A
pr-syncjob 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 notitlefronts no pull request: an admonition, a footer, an issue body.
- 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-*orfix-*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.
- repomatic.github.pr.release_pr(base)[source]¶
The open release pull request, with how far it lags base.
- 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.
- 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 wedgeupsert_pr()’s whole retire path.
- 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.
- 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
mainthat 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.
- 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
HEADnever 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 agit checkoutof 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:
- Return type:
- 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 ofPrOperation.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
HEADis 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 detachedHEAD, to the repository default branch the CI event payload names.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:
