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
That action did this job for years, and this module is a deliberate port of its
algorithm rather than a fresh design: see docs/security.md for the
third-party-action inventory it belongs to. Two properties made porting it
worth the code.
The first is that 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, which is why close_open_prs_on_branch() had to exist as a
separate hand-written reconciler. A command that always runs and decides
internally folds that back into one code path.
The second is that the pieces were 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 lived in YAML.
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 base names the branch to open against, and
the base may have moved on since — the carried commits are replayed onto its
fresh tip. Nothing here is left for peter-evans/create-pull-request, and
tests/test_workflows.py fails if a workflow reaches for it 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().Stands in for the silent close
peter-evans/create-pull-requestperformed by deleting the head branch out from under the PR, which left no trace of why on the conversation.
- 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.
- 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: PrOperation, branch: str, number: int | None = None, url: str = '')[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.list_changed_files(number, repository='')[source]¶
List the repository-relative paths a pull request changes.
Reads the REST files endpoint with
--paginaterather 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, delete_branch=True)[source]¶
Close a pull request with a comment.
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.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 base names the branch to open against. When the base has moved past this checkout, the carried commits are replayed onto its fresh tip.All of that happens on a throwaway local branch, and the original checkout is restored before returning. That matters to
autofix.yaml’ssync-depsjob, which opens four pull requests in sequence from one checkout and needs each to start from a clean tree.- 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, and is required whenHEADis detached.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_set_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
HEADis detached and no base is given, or when base does not exist on remote.- Return type: