Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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;
}