gunnar-grpc 3.0.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! `PullRequests`: open, list, read, close and fast-forward-merge pulls.
//!
//! 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, MergePullRequestRequest,
    MergePullRequestResponse, PullRequestClosed, PullRequestMerged, PullRequestOpened, RefUpdated,
};
use tonic::{Request, Response, Status};

use crate::auth::Access;
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 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 (pull, moved) =
            self.ctx
                .control
                .merge_pull(&path, request.number, &caller.principal_id)?;
        // The same event a push publishes for the same ref move. `forced` is
        // honestly false: the seam refused anything but a fast-forward.
        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) }))
    }
}