use forage_core::auth::{ AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail, UserProfile, }; use forage_core::platform::{ ApprovalDecisionEntry, ApprovalState, Artifact, ArtifactContext, ArtifactDestination, ArtifactRef, ArtifactSource, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput, Destination, DestinationType, Environment, ForestPlatform, NotificationPreference, Organisation, OrgMember, PipelineStage, PipelineStageConfig, PlanOutput, PlatformError, Policy, PolicyConfig, PolicyEvaluation, ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput, UpdateTriggerInput, }; use forage_grpc::policy_service_client::PolicyServiceClient; use forage_grpc::release_pipeline_service_client::ReleasePipelineServiceClient; use forage_grpc::trigger_service_client::TriggerServiceClient; use forage_grpc::destination_service_client::DestinationServiceClient; use forage_grpc::environment_service_client::EnvironmentServiceClient; use forage_grpc::organisation_service_client::OrganisationServiceClient; use forage_grpc::release_service_client::ReleaseServiceClient; use forage_grpc::users_service_client::UsersServiceClient; use tonic::metadata::MetadataValue; use tonic::transport::Channel; use tonic::Request; fn bearer_request(access_token: &str, msg: T) -> Result, String> { let mut req = Request::new(msg); let bearer: MetadataValue<_> = format!("Bearer {access_token}") .parse() .map_err(|_| "invalid token format".to_string())?; req.metadata_mut().insert("authorization", bearer); Ok(req) } /// Real gRPC client to forest-server's UsersService. #[derive(Clone)] pub struct GrpcForestClient { channel: Channel, } impl GrpcForestClient { /// Create a client that connects lazily (for when server may not be available at startup). pub fn connect_lazy(endpoint: &str) -> anyhow::Result { let channel = Channel::from_shared(endpoint.to_string())?.connect_lazy(); Ok(Self { channel }) } fn client(&self) -> UsersServiceClient { UsersServiceClient::new(self.channel.clone()) } fn org_client(&self) -> OrganisationServiceClient { OrganisationServiceClient::new(self.channel.clone()) } pub(crate) fn artifact_client( &self, ) -> forage_grpc::artifact_service_client::ArtifactServiceClient { forage_grpc::artifact_service_client::ArtifactServiceClient::new(self.channel.clone()) } pub(crate) fn release_client(&self) -> ReleaseServiceClient { ReleaseServiceClient::new(self.channel.clone()) } fn env_client(&self) -> EnvironmentServiceClient { EnvironmentServiceClient::new(self.channel.clone()) } fn dest_client(&self) -> DestinationServiceClient { DestinationServiceClient::new(self.channel.clone()) } fn trigger_client(&self) -> TriggerServiceClient { TriggerServiceClient::new(self.channel.clone()) } fn policy_client(&self) -> PolicyServiceClient { PolicyServiceClient::new(self.channel.clone()) } fn pipeline_client(&self) -> ReleasePipelineServiceClient { ReleasePipelineServiceClient::new(self.channel.clone()) } pub fn event_client( &self, ) -> forage_grpc::event_service_client::EventServiceClient { forage_grpc::event_service_client::EventServiceClient::new(self.channel.clone()) } pub(crate) fn notification_client( &self, ) -> forage_grpc::notification_service_client::NotificationServiceClient { forage_grpc::notification_service_client::NotificationServiceClient::new( self.channel.clone(), ) } fn authed_request(access_token: &str, msg: T) -> Result, AuthError> { bearer_request(access_token, msg).map_err(AuthError::Other) } /// Fetch release intent states using a service token (for background workers). pub async fn get_release_intent_states_with_token( &self, service_token: &str, organisation: &str, project: Option<&str>, include_completed: bool, ) -> Result, String> { let req = bearer_request( service_token, forage_grpc::GetReleaseIntentStatesRequest { organisation: organisation.into(), project: project.map(|p| p.into()), include_completed, }, ) .map_err(|e| format!("invalid token: {e}"))?; let resp = self .release_client() .get_release_intent_states(req) .await .map_err(|e| format!("gRPC: {e}"))?; Ok(resp .into_inner() .release_intents .into_iter() .map(|ri| forage_core::platform::ReleaseIntentState { release_intent_id: ri.release_intent_id, artifact_id: ri.artifact_id, project: ri.project, created_at: ri.created_at, stages: ri.stages.into_iter().map(convert_pipeline_stage_state).collect(), steps: ri.steps.into_iter().map(convert_release_step_state).collect(), }) .collect()) } } fn map_status(status: tonic::Status) -> AuthError { match status.code() { tonic::Code::Unauthenticated => AuthError::InvalidCredentials, tonic::Code::AlreadyExists => AuthError::AlreadyExists(status.message().into()), tonic::Code::PermissionDenied => AuthError::NotAuthenticated, tonic::Code::Unavailable => AuthError::Unavailable(status.message().into()), _ => AuthError::Other(status.message().into()), } } fn convert_user(u: forage_grpc::User) -> User { User { user_id: u.user_id, username: u.username, emails: u .emails .into_iter() .map(|e| UserEmail { email: e.email, verified: e.verified, }) .collect(), } } fn convert_token(t: forage_grpc::PersonalAccessToken) -> PersonalAccessToken { PersonalAccessToken { token_id: t.token_id, name: t.name, scopes: t.scopes, created_at: t.created_at.map(|ts| ts.to_string()), last_used: t.last_used.map(|ts| ts.to_string()), expires_at: t.expires_at.map(|ts| ts.to_string()), } } #[async_trait::async_trait] impl ForestAuth for GrpcForestClient { async fn register( &self, username: &str, email: &str, password: &str, ) -> Result { let resp = self .client() .register(forage_grpc::RegisterRequest { username: username.into(), email: email.into(), password: password.into(), }) .await .map_err(map_status)? .into_inner(); let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?; Ok(AuthTokens { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_in_seconds: tokens.expires_in_seconds, }) } async fn login(&self, identifier: &str, password: &str) -> Result { let login_identifier = if identifier.contains('@') { forage_grpc::login_request::Identifier::Email(identifier.into()) } else { forage_grpc::login_request::Identifier::Username(identifier.into()) }; let resp = self .client() .login(forage_grpc::LoginRequest { identifier: Some(login_identifier), password: password.into(), }) .await .map_err(map_status)? .into_inner(); let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?; Ok(AuthTokens { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_in_seconds: tokens.expires_in_seconds, }) } async fn refresh_token(&self, refresh_token: &str) -> Result { let resp = self .client() .refresh_token(forage_grpc::RefreshTokenRequest { refresh_token: refresh_token.into(), }) .await .map_err(map_status)? .into_inner(); let tokens = resp .tokens .ok_or(AuthError::Other("no tokens in response".into()))?; Ok(AuthTokens { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_in_seconds: tokens.expires_in_seconds, }) } async fn logout(&self, refresh_token: &str) -> Result<(), AuthError> { self.client() .logout(forage_grpc::LogoutRequest { refresh_token: refresh_token.into(), }) .await .map_err(map_status)?; Ok(()) } async fn get_user(&self, access_token: &str) -> Result { let req = Self::authed_request( access_token, forage_grpc::TokenInfoRequest {}, )?; let info = self .client() .token_info(req) .await .map_err(map_status)? .into_inner(); let req = Self::authed_request( access_token, forage_grpc::GetUserRequest { identifier: Some(forage_grpc::get_user_request::Identifier::UserId( info.user_id, )), }, )?; let resp = self .client() .get_user(req) .await .map_err(map_status)? .into_inner(); let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?; Ok(convert_user(user)) } async fn get_user_by_username( &self, access_token: &str, username: &str, ) -> Result { let req = Self::authed_request( access_token, forage_grpc::GetUserRequest { identifier: Some(forage_grpc::get_user_request::Identifier::Username( username.into(), )), }, )?; let resp = self .client() .get_user(req) .await .map_err(map_status)? .into_inner(); let user = resp .user .ok_or(AuthError::Other("no user in response".into()))?; Ok(UserProfile { user_id: user.user_id, username: user.username, created_at: user.created_at.map(|ts| { chrono::DateTime::from_timestamp(ts.seconds, ts.nanos as u32) .map(|dt| dt.to_rfc3339()) .unwrap_or_default() }), }) } async fn list_tokens( &self, access_token: &str, user_id: &str, ) -> Result, AuthError> { let req = Self::authed_request( access_token, forage_grpc::ListPersonalAccessTokensRequest { user_id: user_id.into(), }, )?; let resp = self .client() .list_personal_access_tokens(req) .await .map_err(map_status)? .into_inner(); Ok(resp.tokens.into_iter().map(convert_token).collect()) } async fn create_token( &self, access_token: &str, user_id: &str, name: &str, ) -> Result { let req = Self::authed_request( access_token, forage_grpc::CreatePersonalAccessTokenRequest { user_id: user_id.into(), name: name.into(), scopes: vec![], expires_in_seconds: 0, }, )?; let resp = self .client() .create_personal_access_token(req) .await .map_err(map_status)? .into_inner(); let token = resp .token .ok_or(AuthError::Other("no token in response".into()))?; Ok(CreatedToken { token: convert_token(token), raw_token: resp.raw_token, }) } async fn delete_token( &self, access_token: &str, token_id: &str, ) -> Result<(), AuthError> { let req = Self::authed_request( access_token, forage_grpc::DeletePersonalAccessTokenRequest { token_id: token_id.into(), }, )?; self.client() .delete_personal_access_token(req) .await .map_err(map_status)?; Ok(()) } async fn update_username( &self, access_token: &str, user_id: &str, new_username: &str, ) -> Result { let req = Self::authed_request( access_token, forage_grpc::UpdateUserRequest { user_id: user_id.into(), username: Some(new_username.into()), }, )?; let resp = self .client() .update_user(req) .await .map_err(map_status)? .into_inner(); let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?; Ok(convert_user(user)) } async fn change_password( &self, access_token: &str, user_id: &str, current_password: &str, new_password: &str, ) -> Result<(), AuthError> { let req = Self::authed_request( access_token, forage_grpc::ChangePasswordRequest { user_id: user_id.into(), current_password: current_password.into(), new_password: new_password.into(), }, )?; self.client() .change_password(req) .await .map_err(map_status)?; Ok(()) } async fn add_email( &self, access_token: &str, user_id: &str, email: &str, ) -> Result { let req = Self::authed_request( access_token, forage_grpc::AddEmailRequest { user_id: user_id.into(), email: email.into(), }, )?; let resp = self .client() .add_email(req) .await .map_err(map_status)? .into_inner(); let email = resp.email.ok_or(AuthError::Other("no email in response".into()))?; Ok(UserEmail { email: email.email, verified: email.verified, }) } async fn remove_email( &self, access_token: &str, user_id: &str, email: &str, ) -> Result<(), AuthError> { let req = Self::authed_request( access_token, forage_grpc::RemoveEmailRequest { user_id: user_id.into(), email: email.into(), }, )?; self.client() .remove_email(req) .await .map_err(map_status)?; Ok(()) } } fn convert_organisations( organisations: Vec, roles: Vec, ) -> Vec { organisations .into_iter() .zip(roles) .map(|(org, role)| Organisation { organisation_id: org.organisation_id, name: org.name, role, }) .collect() } fn convert_artifact(a: forage_grpc::Artifact) -> Artifact { let ctx = a.context.unwrap_or_default(); let source = a.source.map(|s| ArtifactSource { user: s.user.filter(|v| !v.is_empty()), email: s.email.filter(|v| !v.is_empty()), source_type: s.source_type.filter(|v| !v.is_empty()), run_url: s.run_url.filter(|v| !v.is_empty()), }); let git_ref = a.r#ref.map(|r| ArtifactRef { commit_sha: r.commit_sha, branch: r.branch.filter(|v| !v.is_empty()), commit_message: r.commit_message.filter(|v| !v.is_empty()), version: r.version.filter(|v| !v.is_empty()), repo_url: r.repo_url.filter(|v| !v.is_empty()), }); let destinations = a .destinations .into_iter() .map(|d| ArtifactDestination { name: d.name, environment: d.environment, type_organisation: if d.type_organisation.is_empty() { None } else { Some(d.type_organisation) }, type_name: if d.type_name.is_empty() { None } else { Some(d.type_name) }, type_version: if d.type_version == 0 { None } else { Some(d.type_version) }, status: if d.status.is_empty() { None } else { Some(d.status) }, }) .collect(); Artifact { artifact_id: a.artifact_id, slug: a.slug, context: ArtifactContext { title: ctx.title, description: if ctx.description.as_deref() == Some("") { None } else { ctx.description }, web: ctx.web.filter(|v| !v.is_empty()), pr: ctx.pr.filter(|v| !v.is_empty()), }, source, git_ref, destinations, created_at: a.created_at, } } fn convert_pipeline_stage(s: forage_grpc::PipelineStage) -> PipelineStage { let config = match s.config { Some(forage_grpc::pipeline_stage::Config::Deploy(d)) => { PipelineStageConfig::Deploy { environment: d.environment } } Some(forage_grpc::pipeline_stage::Config::Wait(w)) => { PipelineStageConfig::Wait { duration_seconds: w.duration_seconds } } Some(forage_grpc::pipeline_stage::Config::Plan(p)) => { PipelineStageConfig::Plan { environment: p.environment, auto_approve: p.auto_approve } } None => PipelineStageConfig::Deploy { environment: String::new() }, }; PipelineStage { id: s.id, depends_on: s.depends_on, config, } } /// Convert a `PipelineStageState` proto message (from GetReleaseIntentStates) /// to the domain type. Same enum mapping as `convert_pipeline_run_stage`. fn convert_pipeline_stage_state( s: forage_grpc::PipelineStageState, ) -> forage_core::platform::PipelineRunStageState { let stage_type = match forage_grpc::PipelineRunStageType::try_from(s.stage_type) { Ok(forage_grpc::PipelineRunStageType::Deploy) => "deploy", Ok(forage_grpc::PipelineRunStageType::Wait) => "wait", Ok(forage_grpc::PipelineRunStageType::Plan) => "plan", _ => "unknown", }; let status = match forage_grpc::PipelineRunStageStatus::try_from(s.status) { Ok(forage_grpc::PipelineRunStageStatus::Pending) => "PENDING", Ok(forage_grpc::PipelineRunStageStatus::Active) => "RUNNING", Ok(forage_grpc::PipelineRunStageStatus::Succeeded) => "SUCCEEDED", Ok(forage_grpc::PipelineRunStageStatus::Failed) => "FAILED", Ok(forage_grpc::PipelineRunStageStatus::Cancelled) => "CANCELLED", Ok(forage_grpc::PipelineRunStageStatus::AwaitingApproval) => "AWAITING_APPROVAL", _ => "PENDING", }; forage_core::platform::PipelineRunStageState { stage_id: s.stage_id, depends_on: s.depends_on, stage_type: stage_type.into(), status: status.into(), environment: s.environment, duration_seconds: s.duration_seconds, queued_at: s.queued_at, started_at: s.started_at, completed_at: s.completed_at, error_message: s.error_message, wait_until: s.wait_until, release_ids: s.release_ids, approval_status: s.approval_status, auto_approve: s.auto_approve, } } fn convert_release_step_state( s: forage_grpc::ReleaseStepState, ) -> forage_core::platform::ReleaseStepState { forage_core::platform::ReleaseStepState { release_id: s.release_id, stage_id: s.stage_id, destination_name: s.destination_name, environment: s.environment, status: s.status, queued_at: s.queued_at, assigned_at: s.assigned_at, started_at: s.started_at, completed_at: s.completed_at, error_message: s.error_message, } } fn convert_stages_to_grpc(stages: &[PipelineStage]) -> Vec { stages .iter() .map(|s| forage_grpc::PipelineStage { id: s.id.clone(), depends_on: s.depends_on.clone(), config: Some(match &s.config { PipelineStageConfig::Deploy { environment } => { forage_grpc::pipeline_stage::Config::Deploy(forage_grpc::DeployStageConfig { environment: environment.clone(), }) } PipelineStageConfig::Wait { duration_seconds } => { forage_grpc::pipeline_stage::Config::Wait(forage_grpc::WaitStageConfig { duration_seconds: *duration_seconds, }) } PipelineStageConfig::Plan { environment, auto_approve } => { forage_grpc::pipeline_stage::Config::Plan(forage_grpc::PlanStageConfig { environment: environment.clone(), auto_approve: *auto_approve, }) } }), }) .collect() } fn convert_release_pipeline(p: forage_grpc::ReleasePipeline) -> ReleasePipeline { ReleasePipeline { id: p.id, name: p.name, enabled: p.enabled, stages: p.stages.into_iter().map(convert_pipeline_stage).collect(), created_at: p.created_at, updated_at: p.updated_at, } } fn convert_trigger(t: forage_grpc::Trigger) -> Trigger { Trigger { id: t.id, name: t.name, enabled: t.enabled, branch_pattern: t.branch_pattern, title_pattern: t.title_pattern, author_pattern: t.author_pattern, commit_message_pattern: t.commit_message_pattern, source_type_pattern: t.source_type_pattern, target_environments: t.target_environments, target_destinations: t.target_destinations, force_release: t.force_release, use_pipeline: t.use_pipeline, created_at: t.created_at, updated_at: t.updated_at, } } fn convert_policy(p: forage_grpc::Policy) -> Policy { let policy_type_str = match forage_grpc::PolicyType::try_from(p.policy_type) { Ok(forage_grpc::PolicyType::SoakTime) => "soak_time", Ok(forage_grpc::PolicyType::BranchRestriction) => "branch_restriction", Ok(forage_grpc::PolicyType::ExternalApproval) => "approval", _ => "unknown", }; let config = match p.config { Some(forage_grpc::policy::Config::SoakTime(c)) => PolicyConfig::SoakTime { source_environment: c.source_environment, target_environment: c.target_environment, duration_seconds: c.duration_seconds, }, Some(forage_grpc::policy::Config::BranchRestriction(c)) => { PolicyConfig::BranchRestriction { target_environment: c.target_environment, branch_pattern: c.branch_pattern, } } Some(forage_grpc::policy::Config::ExternalApproval(c)) => PolicyConfig::Approval { target_environment: c.target_environment, required_approvals: c.required_approvals, }, None => PolicyConfig::SoakTime { source_environment: String::new(), target_environment: String::new(), duration_seconds: 0, }, }; Policy { id: p.id, name: p.name, enabled: p.enabled, policy_type: policy_type_str.into(), config, created_at: p.created_at, updated_at: p.updated_at, } } fn policy_config_to_grpc( config: &PolicyConfig, ) -> (i32, Option) { match config { PolicyConfig::SoakTime { source_environment, target_environment, duration_seconds, } => ( forage_grpc::PolicyType::SoakTime as i32, Some(forage_grpc::create_policy_request::Config::SoakTime( forage_grpc::SoakTimeConfig { source_environment: source_environment.clone(), target_environment: target_environment.clone(), duration_seconds: *duration_seconds, }, )), ), PolicyConfig::BranchRestriction { target_environment, branch_pattern, } => ( forage_grpc::PolicyType::BranchRestriction as i32, Some( forage_grpc::create_policy_request::Config::BranchRestriction( forage_grpc::BranchRestrictionConfig { target_environment: target_environment.clone(), branch_pattern: branch_pattern.clone(), }, ), ), ), PolicyConfig::Approval { target_environment, required_approvals, } => ( forage_grpc::PolicyType::ExternalApproval as i32, Some( forage_grpc::create_policy_request::Config::ExternalApproval( forage_grpc::ExternalApprovalConfig { target_environment: target_environment.clone(), required_approvals: *required_approvals, }, ), ), ), } } fn convert_member(m: forage_grpc::OrganisationMember) -> OrgMember { OrgMember { user_id: m.user_id, username: m.username, role: m.role, joined_at: m.joined_at.map(|ts| ts.to_string()), } } fn map_platform_status(status: tonic::Status) -> PlatformError { match status.code() { tonic::Code::Unauthenticated => PlatformError::NotAuthenticated, tonic::Code::PermissionDenied => { PlatformError::Other(status.message().into()) } tonic::Code::NotFound => PlatformError::NotFound(status.message().into()), tonic::Code::Unavailable => PlatformError::Unavailable(status.message().into()), _ => PlatformError::Other(status.message().into()), } } fn platform_authed_request(access_token: &str, msg: T) -> Result, PlatformError> { bearer_request(access_token, msg).map_err(PlatformError::Other) } #[async_trait::async_trait] impl ForestPlatform for GrpcForestClient { async fn list_my_organisations( &self, access_token: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::ListMyOrganisationsRequest { role: String::new() }, )?; let resp = self .org_client() .list_my_organisations(req) .await .map_err(map_platform_status)? .into_inner(); Ok(convert_organisations(resp.organisations, resp.roles)) } async fn list_projects( &self, access_token: &str, organisation: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::GetProjectsRequest { query: Some(forage_grpc::get_projects_request::Query::Organisation( forage_grpc::OrganisationRef { organisation: organisation.into(), }, )), }, )?; let resp = self .release_client() .get_projects(req) .await .map_err(map_platform_status)? .into_inner(); Ok(resp.projects) } async fn list_artifacts( &self, access_token: &str, organisation: &str, project: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::GetArtifactsByProjectRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), }, )?; let resp = self .release_client() .get_artifacts_by_project(req) .await .map_err(map_platform_status)? .into_inner(); Ok(resp.artifact.into_iter().map(convert_artifact).collect()) } async fn create_organisation( &self, access_token: &str, name: &str, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::CreateOrganisationRequest { name: name.into(), }, )?; let resp = self .org_client() .create_organisation(req) .await .map_err(map_platform_status)? .into_inner(); Ok(resp.organisation_id) } async fn list_members( &self, access_token: &str, organisation_id: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::ListMembersRequest { organisation_id: organisation_id.into(), page_size: 100, page_token: String::new(), }, )?; let resp = self .org_client() .list_members(req) .await .map_err(map_platform_status)? .into_inner(); Ok(resp.members.into_iter().map(convert_member).collect()) } async fn add_member( &self, access_token: &str, organisation_id: &str, user_id: &str, role: &str, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::AddMemberRequest { organisation_id: organisation_id.into(), user_id: user_id.into(), role: role.into(), }, )?; let resp = self .org_client() .add_member(req) .await .map_err(map_platform_status)? .into_inner(); let member = resp .member .ok_or(PlatformError::Other("no member in response".into()))?; Ok(convert_member(member)) } async fn remove_member( &self, access_token: &str, organisation_id: &str, user_id: &str, ) -> Result<(), PlatformError> { let req = platform_authed_request( access_token, forage_grpc::RemoveMemberRequest { organisation_id: organisation_id.into(), user_id: user_id.into(), }, )?; self.org_client() .remove_member(req) .await .map_err(map_platform_status)?; Ok(()) } async fn update_member_role( &self, access_token: &str, organisation_id: &str, user_id: &str, role: &str, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::UpdateMemberRoleRequest { organisation_id: organisation_id.into(), user_id: user_id.into(), role: role.into(), }, )?; let resp = self .org_client() .update_member_role(req) .await .map_err(map_platform_status)? .into_inner(); let member = resp .member .ok_or(PlatformError::Other("no member in response".into()))?; Ok(convert_member(member)) } async fn get_artifact_by_slug( &self, access_token: &str, slug: &str, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::GetArtifactBySlugRequest { slug: slug.into(), }, )?; let resp = self .release_client() .get_artifact_by_slug(req) .await .map_err(map_platform_status)? .into_inner(); let artifact = resp .artifact .ok_or(PlatformError::NotFound("artifact not found".into()))?; Ok(convert_artifact(artifact)) } async fn list_environments( &self, access_token: &str, organisation: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::ListEnvironmentsRequest { organisation: organisation.into(), }, )?; let resp = self .env_client() .list_environments(req) .await .map_err(map_platform_status)? .into_inner(); Ok(resp .environments .into_iter() .map(|e| Environment { id: e.id, organisation: e.organisation, name: e.name, description: e.description.filter(|v| !v.is_empty()), sort_order: e.sort_order, created_at: e.created_at, }) .collect()) } async fn list_destinations( &self, access_token: &str, organisation: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::GetDestinationsRequest { organisation: organisation.into(), }, )?; let resp = self .dest_client() .get_destinations(req) .await .map_err(map_platform_status)? .into_inner(); Ok(resp .destinations .into_iter() .map(|d| Destination { name: d.name, environment: d.environment, organisation: d.organisation, metadata: d.metadata, dest_type: d.r#type.map(|t| DestinationType { organisation: t.organisation, name: t.name, version: t.version, }), }) .collect()) } async fn create_environment( &self, access_token: &str, organisation: &str, name: &str, description: Option<&str>, sort_order: i32, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::CreateEnvironmentRequest { organisation: organisation.into(), name: name.into(), description: description.map(|s| s.to_string()), sort_order, }, )?; let resp = self .env_client() .create_environment(req) .await .map_err(map_platform_status)? .into_inner(); let e = resp .environment .ok_or(PlatformError::Other("no environment in response".into()))?; Ok(Environment { id: e.id, organisation: e.organisation, name: e.name, description: e.description.filter(|v| !v.is_empty()), sort_order: e.sort_order, created_at: e.created_at, }) } async fn create_destination( &self, access_token: &str, organisation: &str, name: &str, environment: &str, metadata: &std::collections::HashMap, dest_type: Option<&forage_core::platform::DestinationType>, ) -> Result<(), PlatformError> { let req = platform_authed_request( access_token, forage_grpc::CreateDestinationRequest { organisation: organisation.into(), name: name.into(), environment: environment.into(), metadata: metadata.clone(), r#type: dest_type.map(|t| forage_grpc::DestinationType { organisation: t.organisation.clone(), name: t.name.clone(), version: t.version, }), }, )?; self.dest_client() .create_destination(req) .await .map_err(map_platform_status)?; Ok(()) } async fn update_destination( &self, access_token: &str, name: &str, metadata: &std::collections::HashMap, ) -> Result<(), PlatformError> { let req = platform_authed_request( access_token, forage_grpc::UpdateDestinationRequest { name: name.into(), metadata: metadata.clone(), }, )?; self.dest_client() .update_destination(req) .await .map_err(map_platform_status)?; Ok(()) } async fn get_destination_states( &self, access_token: &str, organisation: &str, project: Option<&str>, ) -> Result { let req = bearer_request( access_token, forage_grpc::GetDestinationStatesRequest { organisation: organisation.into(), project: project.map(|p| p.into()), }, ) .map_err(|e| PlatformError::Other(e.to_string()))?; let resp = self .release_client() .get_destination_states(req) .await .map_err(map_platform_status)?; let inner = resp.into_inner(); let destinations = inner .destinations .into_iter() .map(|d| forage_core::platform::DestinationState { destination_id: d.destination_id, destination_name: d.destination_name, environment: d.environment, release_id: d.release_id, artifact_id: d.artifact_id, status: d.status, error_message: d.error_message, queued_at: d.queued_at, completed_at: d.completed_at, queue_position: d.queue_position, started_at: d.started_at, }) .collect(); Ok(forage_core::platform::DeploymentStates { destinations, }) } async fn get_release_intent_states( &self, access_token: &str, organisation: &str, project: Option<&str>, include_completed: bool, ) -> Result, PlatformError> { let req = bearer_request( access_token, forage_grpc::GetReleaseIntentStatesRequest { organisation: organisation.into(), project: project.map(|p| p.into()), include_completed, }, ) .map_err(|e| PlatformError::Other(e.to_string()))?; let resp = self .release_client() .get_release_intent_states(req) .await .map_err(map_platform_status)?; Ok(resp .into_inner() .release_intents .into_iter() .map(|ri| forage_core::platform::ReleaseIntentState { release_intent_id: ri.release_intent_id, artifact_id: ri.artifact_id, project: ri.project, created_at: ri.created_at, stages: ri .stages .into_iter() .map(convert_pipeline_stage_state) .collect(), steps: ri .steps .into_iter() .map(convert_release_step_state) .collect(), }) .collect()) } async fn release_artifact( &self, access_token: &str, artifact_id: &str, destinations: &[String], environments: &[String], use_pipeline: bool, ) -> Result<(), PlatformError> { let req = bearer_request( access_token, forage_grpc::ReleaseRequest { artifact_id: artifact_id.into(), destinations: destinations.to_vec(), environments: environments.to_vec(), force: false, use_pipeline, prepare_only: false, }, ) .map_err(|e| PlatformError::Other(e.to_string()))?; self.release_client() .release(req) .await .map_err(map_platform_status)?; Ok(()) } async fn list_triggers( &self, access_token: &str, organisation: &str, project: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::ListTriggersRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), }, )?; let resp = self .trigger_client() .list_triggers(req) .await .map_err(map_platform_status)? .into_inner(); Ok(resp.triggers.into_iter().map(convert_trigger).collect()) } async fn create_trigger( &self, access_token: &str, organisation: &str, project: &str, input: &CreateTriggerInput, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::CreateTriggerRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), name: input.name.clone(), branch_pattern: input.branch_pattern.clone(), title_pattern: input.title_pattern.clone(), author_pattern: input.author_pattern.clone(), commit_message_pattern: input.commit_message_pattern.clone(), source_type_pattern: input.source_type_pattern.clone(), target_environments: input.target_environments.clone(), target_destinations: input.target_destinations.clone(), force_release: input.force_release, use_pipeline: input.use_pipeline, }, )?; let resp = self .trigger_client() .create_trigger(req) .await .map_err(map_platform_status)? .into_inner(); let trigger = resp .trigger .ok_or(PlatformError::Other("no trigger in response".into()))?; Ok(convert_trigger(trigger)) } async fn update_trigger( &self, access_token: &str, organisation: &str, project: &str, name: &str, input: &UpdateTriggerInput, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::UpdateTriggerRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), name: name.into(), enabled: input.enabled, branch_pattern: input.branch_pattern.clone(), title_pattern: input.title_pattern.clone(), author_pattern: input.author_pattern.clone(), commit_message_pattern: input.commit_message_pattern.clone(), source_type_pattern: input.source_type_pattern.clone(), target_environments: input.target_environments.clone(), target_destinations: input.target_destinations.clone(), force_release: input.force_release, use_pipeline: input.use_pipeline, }, )?; let resp = self .trigger_client() .update_trigger(req) .await .map_err(map_platform_status)? .into_inner(); let trigger = resp .trigger .ok_or(PlatformError::Other("no trigger in response".into()))?; Ok(convert_trigger(trigger)) } async fn delete_trigger( &self, access_token: &str, organisation: &str, project: &str, name: &str, ) -> Result<(), PlatformError> { let req = platform_authed_request( access_token, forage_grpc::DeleteTriggerRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), name: name.into(), }, )?; self.trigger_client() .delete_trigger(req) .await .map_err(map_platform_status)?; Ok(()) } async fn list_policies( &self, access_token: &str, organisation: &str, project: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::ListPoliciesRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), }, )?; let resp = self .policy_client() .list_policies(req) .await .map_err(map_platform_status)? .into_inner(); Ok(resp.policies.into_iter().map(convert_policy).collect()) } async fn create_policy( &self, access_token: &str, organisation: &str, project: &str, input: &CreatePolicyInput, ) -> Result { let (policy_type, config) = policy_config_to_grpc(&input.config); let req = platform_authed_request( access_token, forage_grpc::CreatePolicyRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), name: input.name.clone(), policy_type, config, }, )?; let resp = self .policy_client() .create_policy(req) .await .map_err(map_platform_status)? .into_inner(); let policy = resp .policy .ok_or(PlatformError::Other("no policy in response".into()))?; Ok(convert_policy(policy)) } async fn update_policy( &self, access_token: &str, organisation: &str, project: &str, name: &str, input: &UpdatePolicyInput, ) -> Result { let config = input.config.as_ref().map(|c| { let (_, grpc_config) = policy_config_to_grpc(c); match grpc_config { Some(forage_grpc::create_policy_request::Config::SoakTime(s)) => { forage_grpc::update_policy_request::Config::SoakTime(s) } Some(forage_grpc::create_policy_request::Config::BranchRestriction(b)) => { forage_grpc::update_policy_request::Config::BranchRestriction(b) } Some(forage_grpc::create_policy_request::Config::ExternalApproval(a)) => { forage_grpc::update_policy_request::Config::ExternalApproval(a) } None => forage_grpc::update_policy_request::Config::SoakTime( forage_grpc::SoakTimeConfig::default(), ), } }); let req = platform_authed_request( access_token, forage_grpc::UpdatePolicyRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), name: name.into(), enabled: input.enabled, config, }, )?; let resp = self .policy_client() .update_policy(req) .await .map_err(map_platform_status)? .into_inner(); let policy = resp .policy .ok_or(PlatformError::Other("no policy in response".into()))?; Ok(convert_policy(policy)) } async fn delete_policy( &self, access_token: &str, organisation: &str, project: &str, name: &str, ) -> Result<(), PlatformError> { let req = platform_authed_request( access_token, forage_grpc::DeletePolicyRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), name: name.into(), }, )?; self.policy_client() .delete_policy(req) .await .map_err(map_platform_status)?; Ok(()) } async fn list_release_pipelines( &self, access_token: &str, organisation: &str, project: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::ListReleasePipelinesRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), }, )?; let resp = self .pipeline_client() .list_release_pipelines(req) .await .map_err(map_platform_status)? .into_inner(); Ok(resp .pipelines .into_iter() .map(convert_release_pipeline) .collect()) } async fn create_release_pipeline( &self, access_token: &str, organisation: &str, project: &str, input: &CreateReleasePipelineInput, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::CreateReleasePipelineRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), name: input.name.clone(), stages: convert_stages_to_grpc(&input.stages), }, )?; let resp = self .pipeline_client() .create_release_pipeline(req) .await .map_err(map_platform_status)? .into_inner(); let pipeline = resp .pipeline .ok_or(PlatformError::Other("no pipeline in response".into()))?; Ok(convert_release_pipeline(pipeline)) } async fn update_release_pipeline( &self, access_token: &str, organisation: &str, project: &str, name: &str, input: &UpdateReleasePipelineInput, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::UpdateReleasePipelineRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), name: name.into(), enabled: input.enabled, stages: input.stages.as_ref().map(|s| convert_stages_to_grpc(s)).unwrap_or_default(), update_stages: input.stages.is_some(), }, )?; let resp = self .pipeline_client() .update_release_pipeline(req) .await .map_err(map_platform_status)? .into_inner(); let pipeline = resp .pipeline .ok_or(PlatformError::Other("no pipeline in response".into()))?; Ok(convert_release_pipeline(pipeline)) } async fn delete_release_pipeline( &self, access_token: &str, organisation: &str, project: &str, name: &str, ) -> Result<(), PlatformError> { let req = platform_authed_request( access_token, forage_grpc::DeleteReleasePipelineRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), name: name.into(), }, )?; self.pipeline_client() .delete_release_pipeline(req) .await .map_err(map_platform_status)?; Ok(()) } async fn get_artifact_spec( &self, access_token: &str, artifact_id: &str, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::GetArtifactSpecRequest { artifact_id: artifact_id.into(), }, )?; let resp = self .artifact_client() .get_artifact_spec(req) .await .map_err(map_platform_status)?; Ok(resp.into_inner().content) } async fn get_notification_preferences( &self, access_token: &str, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::GetNotificationPreferencesRequest {}, )?; let resp = self .notification_client() .get_notification_preferences(req) .await .map_err(map_platform_status)?; Ok(resp .into_inner() .preferences .into_iter() .map(|p| { let nt = forage_grpc::NotificationType::try_from(p.notification_type) .unwrap_or(forage_grpc::NotificationType::Unspecified); let ch = forage_grpc::NotificationChannel::try_from(p.channel) .unwrap_or(forage_grpc::NotificationChannel::Unspecified); NotificationPreference { notification_type: nt.as_str_name().to_string(), channel: ch.as_str_name().to_string(), enabled: p.enabled, } }) .collect()) } async fn set_notification_preference( &self, access_token: &str, notification_type: &str, channel: &str, enabled: bool, ) -> Result<(), PlatformError> { let nt = forage_grpc::NotificationType::from_str_name(notification_type) .unwrap_or(forage_grpc::NotificationType::Unspecified) as i32; let ch = forage_grpc::NotificationChannel::from_str_name(channel) .unwrap_or(forage_grpc::NotificationChannel::Unspecified) as i32; let req = platform_authed_request( access_token, forage_grpc::SetNotificationPreferenceRequest { notification_type: nt, channel: ch, enabled, }, )?; self.notification_client() .set_notification_preference(req) .await .map_err(map_platform_status)?; Ok(()) } async fn evaluate_policies( &self, access_token: &str, organisation: &str, project: &str, target_environment: &str, release_intent_id: Option<&str>, ) -> Result, PlatformError> { let req = platform_authed_request( access_token, forage_grpc::EvaluatePoliciesRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), target_environment: target_environment.into(), branch: None, release_intent_id: release_intent_id.map(|s| s.to_string()), }, )?; let resp = self .policy_client() .evaluate_policies(req) .await .map_err(map_platform_status)?; Ok(resp .into_inner() .evaluations .into_iter() .map(convert_policy_evaluation) .collect()) } async fn approve_release( &self, access_token: &str, organisation: &str, project: &str, release_intent_id: &str, target_environment: &str, comment: Option<&str>, force_bypass: bool, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::ExternalApproveReleaseRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), release_intent_id: release_intent_id.into(), target_environment: target_environment.into(), comment: comment.map(|s| s.to_string()), force_bypass, }, )?; let resp = self .policy_client() .external_approve_release(req) .await .map_err(map_platform_status)?; Ok(convert_approval_state(resp.into_inner().state)) } async fn reject_release( &self, access_token: &str, organisation: &str, project: &str, release_intent_id: &str, target_environment: &str, comment: Option<&str>, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::ExternalRejectReleaseRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), release_intent_id: release_intent_id.into(), target_environment: target_environment.into(), comment: comment.map(|s| s.to_string()), }, )?; let resp = self .policy_client() .external_reject_release(req) .await .map_err(map_platform_status)?; Ok(convert_approval_state(resp.into_inner().state)) } async fn get_approval_state( &self, access_token: &str, organisation: &str, project: &str, release_intent_id: &str, target_environment: &str, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::GetExternalApprovalStateRequest { project: Some(forage_grpc::Project { organisation: organisation.into(), project: project.into(), }), release_intent_id: release_intent_id.into(), target_environment: target_environment.into(), }, )?; let resp = self .policy_client() .get_external_approval_state(req) .await .map_err(map_platform_status)?; Ok(convert_approval_state(resp.into_inner().state)) } async fn approve_plan_stage( &self, access_token: &str, release_intent_id: &str, stage_id: &str, ) -> Result<(), PlatformError> { let req = platform_authed_request( access_token, forage_grpc::ApprovePlanStageRequest { release_intent_id: release_intent_id.into(), stage_id: stage_id.into(), }, )?; self.release_client() .approve_plan_stage(req) .await .map_err(map_platform_status)?; Ok(()) } async fn reject_plan_stage( &self, access_token: &str, release_intent_id: &str, stage_id: &str, reason: Option<&str>, ) -> Result<(), PlatformError> { let req = platform_authed_request( access_token, forage_grpc::RejectPlanStageRequest { release_intent_id: release_intent_id.into(), stage_id: stage_id.into(), reason: reason.map(|s| s.into()), }, )?; self.release_client() .reject_plan_stage(req) .await .map_err(map_platform_status)?; Ok(()) } async fn get_plan_output( &self, access_token: &str, release_intent_id: &str, stage_id: &str, ) -> Result { let req = platform_authed_request( access_token, forage_grpc::GetPlanOutputRequest { release_intent_id: release_intent_id.into(), stage_id: stage_id.into(), }, )?; let resp = self .release_client() .get_plan_output(req) .await .map_err(map_platform_status)?; let inner = resp.into_inner(); Ok(PlanOutput { plan_output: inner.plan_output, status: inner.status, outputs: inner.outputs.into_iter().map(|o| { forage_core::platform::PlanDestinationOutput { destination_id: o.destination_id, destination_name: o.destination_name, plan_output: o.plan_output, status: o.status, } }).collect(), }) } } fn convert_policy_evaluation(e: forage_grpc::PolicyEvaluation) -> PolicyEvaluation { let policy_type = match e.policy_type { 1 => "soak_time", 2 => "branch_restriction", 3 => "approval", _ => "unknown", }; let approval_state = e.external_approval_state.map(|s| convert_approval_state(Some(s))); PolicyEvaluation { policy_name: e.policy_name, policy_type: policy_type.into(), passed: e.passed, reason: e.reason, approval_state, } } fn convert_approval_state(state: Option) -> ApprovalState { match state { Some(s) => ApprovalState { required_approvals: s.required_approvals, current_approvals: s.current_approvals, decisions: s .decisions .into_iter() .map(|d| ApprovalDecisionEntry { user_id: d.user_id, username: d.username, decision: d.decision, decided_at: d.decided_at, comment: d.comment, }) .collect(), }, None => ApprovalState { required_approvals: 0, current_approvals: 0, decisions: vec![], }, } } #[cfg(test)] mod tests { use super::*; fn make_org(id: &str, name: &str) -> forage_grpc::Organisation { forage_grpc::Organisation { organisation_id: id.into(), name: name.into(), ..Default::default() } } fn make_artifact(slug: &str, ctx: Option) -> forage_grpc::Artifact { forage_grpc::Artifact { artifact_id: "a1".into(), slug: slug.into(), context: ctx, created_at: "2026-01-01".into(), ..Default::default() } } #[test] fn convert_organisations_pairs_orgs_with_roles() { let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")]; let roles = vec!["owner".into(), "member".into()]; let result = convert_organisations(orgs, roles); assert_eq!(result.len(), 2); assert_eq!(result[0].name, "alpha"); assert_eq!(result[0].role, "owner"); assert_eq!(result[1].name, "beta"); assert_eq!(result[1].role, "member"); } #[test] fn convert_organisations_truncates_when_roles_shorter() { let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")]; let roles = vec!["owner".into()]; // only 1 role for 2 orgs let result = convert_organisations(orgs, roles); // zip truncates to shorter iterator assert_eq!(result.len(), 1); assert_eq!(result[0].name, "alpha"); } #[test] fn convert_organisations_empty() { let result = convert_organisations(vec![], vec![]); assert!(result.is_empty()); } #[test] fn convert_artifact_with_full_context() { let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext { title: "My API".into(), description: Some("A cool API".into()), ..Default::default() })); let result = convert_artifact(a); assert_eq!(result.slug, "my-api"); assert_eq!(result.context.title, "My API"); assert_eq!(result.context.description.as_deref(), Some("A cool API")); } #[test] fn convert_artifact_empty_description_becomes_none() { let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext { title: "My API".into(), description: Some(String::new()), ..Default::default() })); let result = convert_artifact(a); assert!(result.context.description.is_none()); } #[test] fn convert_artifact_missing_context_uses_defaults() { let a = make_artifact("my-api", None); let result = convert_artifact(a); assert_eq!(result.context.title, ""); assert!(result.context.description.is_none()); } #[test] fn convert_artifact_none_description_stays_none() { let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext { title: "My API".into(), description: None, ..Default::default() })); let result = convert_artifact(a); assert!(result.context.description.is_none()); } }