feat: add approval step

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-15 19:46:33 +01:00
parent 533b738692
commit 7eb6ae7cbb
41 changed files with 7886 additions and 1724 deletions

View File

@@ -3,11 +3,12 @@ use forage_core::auth::{
UserProfile,
};
use forage_core::platform::{
Artifact, ArtifactContext, ArtifactDestination, ArtifactRef, ArtifactSource, CreatePolicyInput,
CreateReleasePipelineInput, CreateTriggerInput, Destination, DestinationType, Environment,
ForestPlatform, NotificationPreference, Organisation, OrgMember, PipelineStage,
PipelineStageConfig, PlatformError, Policy, PolicyConfig, ReleasePipeline, Trigger,
UpdatePolicyInput, UpdateReleasePipelineInput, UpdateTriggerInput,
ApprovalDecisionEntry, ApprovalState, Artifact, ArtifactContext, ArtifactDestination,
ArtifactRef, ArtifactSource, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput,
Destination, DestinationType, Environment, ForestPlatform, NotificationPreference, Organisation,
OrgMember, PipelineStage, PipelineStageConfig, PlatformError, Policy, PolicyConfig,
PolicyEvaluation, ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput,
UpdateTriggerInput,
};
use forage_grpc::policy_service_client::PolicyServiceClient;
use forage_grpc::release_pipeline_service_client::ReleasePipelineServiceClient;
@@ -582,6 +583,9 @@ fn convert_pipeline_stage(s: forage_grpc::PipelineStage) -> PipelineStage {
Some(forage_grpc::pipeline_stage::Config::Wait(w)) => {
PipelineStageConfig::Wait { duration_seconds: w.duration_seconds }
}
Some(forage_grpc::pipeline_stage::Config::Plan(_)) => {
PipelineStageConfig::Deploy { environment: String::new() }
}
None => PipelineStageConfig::Deploy { environment: String::new() },
};
PipelineStage {
@@ -698,6 +702,7 @@ 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 {
@@ -712,6 +717,10 @@ fn convert_policy(p: forage_grpc::Policy) -> Policy {
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(),
@@ -761,6 +770,20 @@ fn policy_config_to_grpc(
),
),
),
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,
},
),
),
),
}
}
@@ -775,8 +798,9 @@ fn convert_member(m: forage_grpc::OrganisationMember) -> OrgMember {
fn map_platform_status(status: tonic::Status) -> PlatformError {
match status.code() {
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
PlatformError::NotAuthenticated
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()),
@@ -1270,6 +1294,7 @@ impl ForestPlatform for GrpcForestClient {
environments: environments.to_vec(),
force: false,
use_pipeline,
prepare_only: false,
},
)
.map_err(|e| PlatformError::Other(e.to_string()))?;
@@ -1481,6 +1506,9 @@ impl ForestPlatform for GrpcForestClient {
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(),
),
@@ -1724,6 +1752,168 @@ impl ForestPlatform for GrpcForestClient {
.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))
}
}
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.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)]