crates/gunnar-ui-server/proto/gunnar/v1/pulls.proto
syntax = "proto3";
package gunnar.v1;
import "gunnar/v1/common.proto";
// Pull requests. Records live in the control store (`refs/gunnar/pulls`,
// the catalog's own CAS discipline); a merge moves the target ref through the
// SAME compare-and-swap primitive receive-pack uses, after the same ancestry
// function branch protection trusts.
//
// A merge is a fast-forward where the branch descends from the target, and
// otherwise a merge commit (GUN-MERGE-ENGINE, 2026-11-01): gunnar's own
// three-way merge over the store, conflicts refused by path, nothing written
// until every gate has passed. THE APPROVAL RULE: an approval names a commit,
// and approvals of the pull request's head authorise merging exactly that head
// into the target tip the merge finds. The merge commit is new, so it records
// what it was authorised by: `Head:`, `Target:` and one `Approved-by:` line per
// approver. Its author is the person who pressed Merge; its committer is
// gunnar. This is why `Refs` staying read-only holds: the one ref move this
// service performs is the one it exists to gate.
service PullRequests {
rpc Create(CreatePullRequestRequest) returns (CreatePullRequestResponse);
rpc List(ListPullRequestsRequest) returns (ListPullRequestsResponse);
rpc Get(GetPullRequestRequest) returns (GetPullRequestResponse);
rpc Close(ClosePullRequestRequest) returns (ClosePullRequestResponse);
rpc Merge(MergePullRequestRequest) returns (MergePullRequestResponse);
// Every check Merge makes, and no write: the answer is the pull request as it
// stands with the MergePlan (strategy, target tip, conflicts), or Merge's own
// refusal word for word. Conflicts are the one refusal answered as data
// (`MergePlan.conflicts`), so a console can list the paths before the click.
// A console offers Merge only when this says yes with no conflicts
// (GUN-FIELD-TEST, GUN-MERGE-ENGINE 2026-11-01). A new RPC rather than a flag on Merge: an
// older gunnar answers Unimplemented here, where it would ignore an unknown flag
// and MERGE.
rpc CheckMerge(MergePullRequestRequest) returns (MergePullRequestResponse);
// **The public half of the key gunnar signs its merge commits with**
// (GUN-MERGE-SIGN, 2026-11-02). Every merge or squash commit gunnar writes
// carries an SSH signature (namespace `git`) by this server's own key, minted on
// the box at first use, sealed there, and never handed out: only this public
// half. A repository that requires signed commits takes gunnar's merge commits
// (the key counts as a server key in the signed-commits check), and a git client
// verifies them with `gpg.ssh.allowedSignersFile` holding `allowed_signers`.
// Any signed-in caller may ask: it is a public key.
rpc MergeSigningKey(MergeSigningKeyRequest) returns (MergeSigningKeyResponse);
}
message MergeSigningKeyRequest {}
message MergeSigningKeyResponse {
// `ssh-ed25519 AAAA… gunnar-merge-signing`, one OpenSSH line.
string openssh = 1;
// `SHA256:…`.
string fingerprint_sha256 = 2;
// One line for git's allowed signers file:
// `noreply@gunnar.invalid namespaces="git" ssh-ed25519 AAAA…`.
string allowed_signers = 3;
}
enum PullRequestState {
PULL_REQUEST_STATE_UNSPECIFIED = 0;
PULL_REQUEST_STATE_OPEN = 1;
// Terminal: the target ref moved.
PULL_REQUEST_STATE_MERGED = 2;
// Terminal: declined without merging.
PULL_REQUEST_STATE_CLOSED = 3;
}
message PullRequest {
RepoPath repo = 1;
// Per-repository, dense, never reused.
uint64 number = 2;
string title = 3;
string description = 4;
// Full ref names, both of them.
string source_ref = 5;
string target_ref = 6;
PullRequestState state = 7;
// The principal that opened it.
string author = 8;
int64 created_at_unix_ms = 9;
int64 updated_at_unix_ms = 10;
// The source tip AT CREATION — a review anchor, not the merge result.
string source_oid = 11;
// The oid the target ref moved to. Empty unless MERGED.
string merge_oid = 12;
}
message CreatePullRequestRequest {
RepoPath repo = 1;
string title = 2;
string description = 3;
// Full ref names under refs/heads/. Both must resolve when Create runs.
string source_ref = 4;
string target_ref = 5;
}
message CreatePullRequestResponse {
PullRequest pull = 1;
}
message ListPullRequestsRequest {
RepoPath repo = 1;
// UNSPECIFIED means every state.
PullRequestState state = 2;
}
message ListPullRequestsResponse {
repeated PullRequest pulls = 1;
}
message GetPullRequestRequest {
RepoPath repo = 1;
uint64 number = 2;
}
message GetPullRequestResponse {
PullRequest pull = 1;
}
message ClosePullRequestRequest {
RepoPath repo = 1;
uint64 number = 2;
}
message ClosePullRequestResponse {
PullRequest pull = 1;
}
// How a pull request is merged (GUN-MERGE-ENGINE, 2026-11-01).
enum MergeStrategy {
// gunnar picks: a fast-forward when the head descends from the target and the
// repository allows it, otherwise a merge commit (or a squash where only that
// is allowed).
MERGE_STRATEGY_UNSPECIFIED = 0;
// Move the target to the approved head. Refused when the head does not
// descend from the target.
MERGE_STRATEGY_FAST_FORWARD = 1;
// A new commit with two parents, the target tip and the approved head
// (`git merge --no-ff`), even where a fast-forward was possible.
MERGE_STRATEGY_MERGE_COMMIT = 2;
// A new commit with the merged tree and the target tip as its only parent.
// Only where the repository's policy allows it (`merge.allow_squash`).
MERGE_STRATEGY_SQUASH = 3;
}
message MergePullRequestRequest {
RepoPath repo = 1;
uint64 number = 2;
// UNSPECIFIED lets gunnar pick (see MergeStrategy).
MergeStrategy strategy = 3;
// The target tip the caller's CheckMerge answered (`MergePlan.target_oid`).
// Empty: none. When the target has moved since, gunnar checks the merge again
// against the new tip (every gate, the conflicts) and says so in
// `MergePlan.rechecked`; it never merges on the old answer.
string expected_target_oid = 4;
}
// What a merge does, or did (GUN-MERGE-ENGINE, 2026-11-01).
message MergePlan {
// The strategy gunnar chose. UNSPECIFIED only when there is nothing to move
// (the target already holds the head).
MergeStrategy strategy = 1;
// The approved head commit the merge takes.
string head_oid = 2;
// The target tip the merge is made onto.
string target_oid = 3;
// Merge: the commit the target ref now names. CheckMerge: the head for a
// fast-forward, empty for a commit not written yet.
string result_oid = 4;
// CheckMerge only: every path that conflicts, as `path (why)`. Non-empty
// means Merge refuses, naming the same paths; nothing is written.
repeated string conflicts = 5;
// The principals whose verified approvals of the head authorise the merge.
repeated string approvers = 6;
// The target moved after `expected_target_oid` (or during the merge) and
// gunnar checked again against the new tip.
bool rechecked = 7;
// The head is already in the target (merged outside Gunnar): the commit on
// the target that contains it. Nothing is left to merge and no ref moves, so
// no approval is asked; Merge marks the pull request merged at this commit.
string contained_in = 8;
}
message MergePullRequestResponse {
PullRequest pull = 1;
MergePlan plan = 2;
}