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

nordisk/gunnar

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}")
}