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-request performed 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: 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: PrOperation, branch: str, number: int | None = None, url: str = '')[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.list_changed_files(number, repository='')[source]

List the repository-relative paths a pull request changes.

Reads the REST files endpoint with --paginate 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, 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 wedge upsert_pr()’s whole retire path.

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

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

  • delete_branch (bool) – When True, also delete the head branch.

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.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 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’s sync-deps job, 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 when HEAD is detached.

  • 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 _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 HEAD is detached and no base is given, or when base does not exist on remote.

Return type:

PrSyncResult