Files
client/crates/forage-server/src/forest_client.rs
2026-03-15 22:38:42 +01:00

2103 lines
66 KiB
Rust

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<T>(access_token: &str, msg: T) -> Result<Request<T>, 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<Self> {
let channel = Channel::from_shared(endpoint.to_string())?.connect_lazy();
Ok(Self { channel })
}
fn client(&self) -> UsersServiceClient<Channel> {
UsersServiceClient::new(self.channel.clone())
}
fn org_client(&self) -> OrganisationServiceClient<Channel> {
OrganisationServiceClient::new(self.channel.clone())
}
pub(crate) fn artifact_client(
&self,
) -> forage_grpc::artifact_service_client::ArtifactServiceClient<Channel> {
forage_grpc::artifact_service_client::ArtifactServiceClient::new(self.channel.clone())
}
pub(crate) fn release_client(&self) -> ReleaseServiceClient<Channel> {
ReleaseServiceClient::new(self.channel.clone())
}
fn env_client(&self) -> EnvironmentServiceClient<Channel> {
EnvironmentServiceClient::new(self.channel.clone())
}
fn dest_client(&self) -> DestinationServiceClient<Channel> {
DestinationServiceClient::new(self.channel.clone())
}
fn trigger_client(&self) -> TriggerServiceClient<Channel> {
TriggerServiceClient::new(self.channel.clone())
}
fn policy_client(&self) -> PolicyServiceClient<Channel> {
PolicyServiceClient::new(self.channel.clone())
}
fn pipeline_client(&self) -> ReleasePipelineServiceClient<Channel> {
ReleasePipelineServiceClient::new(self.channel.clone())
}
pub fn event_client(
&self,
) -> forage_grpc::event_service_client::EventServiceClient<Channel> {
forage_grpc::event_service_client::EventServiceClient::new(self.channel.clone())
}
pub(crate) fn notification_client(
&self,
) -> forage_grpc::notification_service_client::NotificationServiceClient<Channel> {
forage_grpc::notification_service_client::NotificationServiceClient::new(
self.channel.clone(),
)
}
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, 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<Vec<forage_core::platform::ReleaseIntentState>, 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<AuthTokens, AuthError> {
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<AuthTokens, AuthError> {
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<AuthTokens, AuthError> {
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<User, AuthError> {
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<UserProfile, AuthError> {
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<Vec<PersonalAccessToken>, 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<CreatedToken, AuthError> {
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<User, AuthError> {
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<UserEmail, AuthError> {
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<forage_grpc::Organisation>,
roles: Vec<String>,
) -> Vec<Organisation> {
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<forage_grpc::PipelineStage> {
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<forage_grpc::create_policy_request::Config>) {
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<T>(access_token: &str, msg: T) -> Result<Request<T>, 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<Vec<Organisation>, 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<Vec<String>, 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<Vec<Artifact>, 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<String, PlatformError> {
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<Vec<OrgMember>, 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<OrgMember, PlatformError> {
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<OrgMember, PlatformError> {
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<Artifact, PlatformError> {
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<Vec<Environment>, 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<Vec<Destination>, 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<Environment, PlatformError> {
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<String, String>,
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<String, String>,
) -> 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<forage_core::platform::DeploymentStates, PlatformError> {
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<Vec<forage_core::platform::ReleaseIntentState>, 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<Vec<Trigger>, 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<Trigger, PlatformError> {
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<Trigger, PlatformError> {
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<Vec<Policy>, 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<Policy, PlatformError> {
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<Policy, PlatformError> {
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<Vec<ReleasePipeline>, 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<ReleasePipeline, PlatformError> {
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<ReleasePipeline, PlatformError> {
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<String, PlatformError> {
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<Vec<NotificationPreference>, 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<Vec<PolicyEvaluation>, 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<ApprovalState, PlatformError> {
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<ApprovalState, PlatformError> {
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<ApprovalState, PlatformError> {
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<PlanOutput, PlatformError> {
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<forage_grpc::ExternalApprovalState>) -> 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::ArtifactContext>) -> 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());
}
}