crates/gunnar-grpc/src/service/pulls.rs
//! `PullRequests`: open, list, read, close and merge pulls (fast-forward, merge commit or
//! squash; the merge engine is the server's, behind the seam).
//!
//! The records live behind the seam; the ONE ref move (`Merge`) happens there
//! too, through the store's own compare-and-swap. What this layer owns is what
//! every service layer owns: authorize-first, and the event.
//!
//! # Grant levels
//!
//! Create, Close and Merge take **Write** on the repository — a merge is a ref
//! move, and Write is exactly what moves refs over SSH; demanding Admin here
//! would make the gRPC door stricter than the git door for the same change.
//! Get and List take Read. "Who may merge into main" is branch-protection
//! policy the day Policy lands, not a grant level.
//!
//! # The events
//!
//! Open, merge and close each publish their pull-request event, and a merge
//! ALSO publishes the same [`RefUpdated`] a push would — the pr event carries
//! the review-level fact (which pull, by whom), the ref event carries the
//! mechanics, and mirrors or watchers that only know refs keep working.
use gunnar_proto::pull_requests_server::PullRequests;
use gunnar_proto::{
event, ClosePullRequestRequest, ClosePullRequestResponse, CreatePullRequestRequest,
CreatePullRequestResponse, GetPullRequestRequest, GetPullRequestResponse,
ListPullRequestsRequest, ListPullRequestsResponse, MergePlan, MergePullRequestRequest,
MergePullRequestResponse, MergeSigningKeyRequest, MergeSigningKeyResponse, PullRequestClosed,
PullRequestMerged, PullRequestOpened, RefUpdated,
};
use tonic::{Request, Response, Status};
use crate::auth::Access;
use crate::seam::{MergeOptions, MergedRef};
use crate::service::PullsService;
use crate::status::repo_path;
use gunnar_proto::GrantLevel;
#[tonic::async_trait]
impl PullRequests for PullsService {
async fn create(
&self,
request: Request<CreatePullRequestRequest>,
) -> Result<Response<CreatePullRequestResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.repo, "repo")?;
self.ctx.require(
&caller,
Access::repo(&path),
GrantLevel::Write,
"PullRequests.Create",
)?;
let pull = self.ctx.control.create_pull(
&path,
&caller.principal_id,
&request.title,
&request.description,
&request.source_ref,
&request.target_ref,
)?;
self.ctx
.bus
.publish(event::Payload::PullRequestOpened(PullRequestOpened {
repo: Some(path),
number: pull.number,
title: pull.title.clone(),
source_ref: pull.source_ref.clone(),
target_ref: pull.target_ref.clone(),
principal_id: caller.principal_id.clone(),
}));
Ok(Response::new(CreatePullRequestResponse {
pull: Some(pull),
}))
}
async fn list(
&self,
request: Request<ListPullRequestsRequest>,
) -> Result<Response<ListPullRequestsResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.repo, "repo")?;
self.ctx.require(
&caller,
Access::repo(&path),
GrantLevel::Read,
"PullRequests.List",
)?;
let pulls = self.ctx.control.list_pulls(&path, request.state)?;
Ok(Response::new(ListPullRequestsResponse { pulls }))
}
async fn get(
&self,
request: Request<GetPullRequestRequest>,
) -> Result<Response<GetPullRequestResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.repo, "repo")?;
self.ctx.require(
&caller,
Access::repo(&path),
GrantLevel::Read,
"PullRequests.Get",
)?;
let pull = self.ctx.control.get_pull(&path, request.number)?;
Ok(Response::new(GetPullRequestResponse { pull: Some(pull) }))
}
async fn close(
&self,
request: Request<ClosePullRequestRequest>,
) -> Result<Response<ClosePullRequestResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.repo, "repo")?;
self.ctx.require(
&caller,
Access::repo(&path),
GrantLevel::Write,
"PullRequests.Close",
)?;
let pull = self.ctx.control.close_pull(&path, request.number)?;
self.ctx
.bus
.publish(event::Payload::PullRequestClosed(PullRequestClosed {
repo: Some(path),
number: pull.number,
title: pull.title.clone(),
principal_id: caller.principal_id.clone(),
}));
Ok(Response::new(ClosePullRequestResponse { pull: Some(pull) }))
}
async fn check_merge(
&self,
request: Request<MergePullRequestRequest>,
) -> Result<Response<MergePullRequestResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.repo, "repo")?;
// The same grant the merge asks: the check tells a person what THEY could do.
self.ctx.require(
&caller,
Access::repo(&path),
GrantLevel::Write,
"PullRequests.CheckMerge",
)?;
let options = MergeOptions::from_request(request.strategy, &request.expected_target_oid)?;
let (pull, would) =
self.ctx
.control
.check_merge(&path, request.number, &caller.principal_id, &options)?;
Ok(Response::new(MergePullRequestResponse {
pull: Some(pull),
plan: Some(plan_of(&would)),
}))
}
async fn merge_signing_key(
&self,
request: Request<MergeSigningKeyRequest>,
) -> Result<Response<MergeSigningKeyResponse>, Status> {
// Any signed-in caller: it is a public key (GUN-MERGE-SIGN).
let _caller = self.ctx.caller(&request)?;
let (openssh, fingerprint_sha256) = self.ctx.control.merge_signing_key()?;
let allowed_signers = allowed_signers_line(&openssh);
Ok(Response::new(MergeSigningKeyResponse {
openssh,
fingerprint_sha256,
allowed_signers,
}))
}
async fn merge(
&self,
request: Request<MergePullRequestRequest>,
) -> Result<Response<MergePullRequestResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.repo, "repo")?;
self.ctx.require(
&caller,
Access::repo(&path),
GrantLevel::Write,
"PullRequests.Merge",
)?;
let options = MergeOptions::from_request(request.strategy, &request.expected_target_oid)?;
let (pull, moved) =
self.ctx
.control
.merge_pull(&path, request.number, &caller.principal_id, &options)?;
let plan = plan_of(&moved);
// ★ SEC-G1: a head already in the target that nobody approved is CLOSED, never
// merged: no PullRequestMerged, and no RefUpdated (nothing moved).
if !moved.closed.is_empty() {
self.ctx
.bus
.publish(event::Payload::PullRequestClosed(PullRequestClosed {
repo: Some(path),
number: pull.number,
title: pull.title.clone(),
principal_id: caller.principal_id.clone(),
}));
return Ok(Response::new(MergePullRequestResponse {
pull: Some(pull),
plan: Some(plan),
}));
}
// The same event a push publishes for the same ref move. `forced` is
// honestly false: a fast-forward descends from the old tip, and so does a
// merge commit or a squash (its first parent is the old tip). A merge outside
// Gunnar moved nothing: no RefUpdated where old == new (SEC-G1).
if moved.old_oid != moved.new_oid {
self.ctx.bus.publish(event::Payload::RefUpdated(RefUpdated {
repo: Some(path.clone()),
ref_name: moved.ref_name,
old_oid: moved.old_oid,
new_oid: moved.new_oid.clone(),
principal_id: caller.principal_id.clone(),
key_fingerprint: caller.fingerprint.clone(),
// The control plane's own credential, in the same vocabulary the
// git transports use (GUN-014). A merge is a ref move like a push
// and belongs on the stream the same way.
credential: format!("key:{}", caller.fingerprint),
forced: false,
}));
}
self.ctx
.bus
.publish(event::Payload::PullRequestMerged(PullRequestMerged {
repo: Some(path),
number: pull.number,
title: pull.title.clone(),
target_ref: pull.target_ref.clone(),
merge_oid: moved.new_oid,
principal_id: caller.principal_id.clone(),
}));
Ok(Response::new(MergePullRequestResponse {
pull: Some(pull),
plan: Some(plan),
}))
}
}
/// The wire's `MergePlan` for what the seam answered.
fn plan_of(merged: &MergedRef) -> MergePlan {
MergePlan {
strategy: merged.strategy as i32,
head_oid: merged.head_oid.clone(),
target_oid: merged.old_oid.clone(),
result_oid: merged.new_oid.clone(),
conflicts: merged.conflicts.clone(),
approvers: merged.approvers.clone(),
rechecked: merged.rechecked,
contained_in: merged.contained_in.clone(),
}
}
/// **The address gunnar commits a merge as** (the committer of every merge commit).
pub const MERGE_COMMITTER_EMAIL: &str = "noreply@gunnar.invalid";
/// One line of git's allowed signers file for the merge-signing key: the committer's
/// address, the `git` namespace only, and the key's type and blob (its comment dropped).
pub fn allowed_signers_line(openssh: &str) -> String {
let mut fields = openssh.split_whitespace();
let (kind, blob) = (
fields.next().unwrap_or_default(),
fields.next().unwrap_or_default(),
);
format!("{MERGE_COMMITTER_EMAIL} namespaces=\"git\" {kind} {blob}")
}