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

@@ -247,6 +247,10 @@ pub enum PolicyConfig {
target_environment: String,
branch_pattern: String,
},
Approval {
target_environment: String,
required_approvals: i32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -267,6 +271,24 @@ pub struct PolicyEvaluation {
pub policy_type: String,
pub passed: bool,
pub reason: String,
#[serde(default)]
pub approval_state: Option<ApprovalState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalState {
pub required_approvals: i32,
pub current_approvals: i32,
pub decisions: Vec<ApprovalDecisionEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalDecisionEntry {
pub user_id: String,
pub username: String,
pub decision: String,
pub decided_at: String,
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -567,6 +589,45 @@ pub trait ForestPlatform: Send + Sync {
channel: &str,
enabled: bool,
) -> Result<(), PlatformError>;
async fn evaluate_policies(
&self,
access_token: &str,
organisation: &str,
project: &str,
target_environment: &str,
release_intent_id: Option<&str>,
) -> Result<Vec<PolicyEvaluation>, PlatformError>;
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>;
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>;
async fn get_approval_state(
&self,
access_token: &str,
organisation: &str,
project: &str,
release_intent_id: &str,
target_environment: &str,
) -> Result<ApprovalState, PlatformError>;
}
#[cfg(test)]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -108,14 +108,17 @@ impl FromRequestParts<AppState> for Session {
}
}
} else {
// Backfill: if we have a user but empty orgs, try to fetch them.
// This handles the case where list_my_organisations failed during login.
let needs_org_backfill = session_data
// Refresh orgs if they're empty OR if the session hasn't been seen
// for a while (e.g. after server restart, PG session loaded with stale orgs).
let now = chrono::Utc::now();
let orgs_empty = session_data
.user
.as_ref()
.is_some_and(|u| u.orgs.is_empty());
let orgs_stale = now - session_data.last_seen_at > chrono::Duration::minutes(5);
let needs_org_refresh = orgs_empty || orgs_stale;
if needs_org_backfill {
if needs_org_refresh {
if let Ok(orgs) = state
.platform_client
.list_my_organisations(&session_data.access_token)
@@ -126,7 +129,8 @@ impl FromRequestParts<AppState> for Session {
tracing::info!(
user_id = %user.user_id,
org_count = orgs.len(),
"backfilled empty org list"
was_empty = orgs_empty,
"refreshed org list"
);
user.orgs = orgs
.into_iter()

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)]

View File

@@ -100,6 +100,14 @@ pub fn router() -> Router<AppState> {
"/orgs/{org}/projects/{project}/policies/{name}/delete",
post(delete_policy),
)
.route(
"/orgs/{org}/projects/{project}/releases/{slug}/approve",
post(approve_release_submit),
)
.route(
"/orgs/{org}/projects/{project}/releases/{slug}/reject",
post(reject_release_submit),
)
.route(
"/orgs/{org}/projects/{project}/pipelines",
get(pipelines_page).post(create_pipeline_submit),
@@ -682,7 +690,8 @@ async fn project_detail(
Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
let current_org = require_org_membership(&state, orgs, &org)?;
let current_role = current_org.role.clone();
if !validate_slug(&project) {
return Err(error_page(
@@ -767,6 +776,7 @@ async fn project_detail(
org_name => &org,
project_name => &project,
projects => projects,
current_role => &current_role,
active_tab => "project_overview",
timeline => data.timeline,
lanes => data.lanes,
@@ -968,6 +978,74 @@ async fn artifact_detail(
let has_pipeline = !pipeline_stages.is_empty() || project_has_pipeline;
// Fetch policy evaluations for active release intents.
let mut policy_evaluations: Vec<minijinja::Value> = Vec::new();
let mut release_intent_id_str = String::new();
let is_release_author = false;
for ri in &release_intents {
if ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty() {
release_intent_id_str = ri.release_intent_id.clone();
// Collect unique environments from the pipeline stages.
let environments: Vec<String> = ri
.stages
.iter()
.filter_map(|s| s.environment.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
for env in &environments {
if let Ok(evals) = state
.platform_client
.evaluate_policies(
&session.access_token,
&org,
&project,
env,
Some(&ri.release_intent_id),
)
.await
{
for eval in evals {
let approval_state_ctx = eval.approval_state.as_ref().map(|s| {
let decisions: Vec<minijinja::Value> = s
.decisions
.iter()
.map(|d| {
context! {
username => d.username,
decision => d.decision,
comment => d.comment,
decided_at => d.decided_at,
}
})
.collect();
context! {
required_approvals => s.required_approvals,
current_approvals => s.current_approvals,
decisions => decisions,
}
});
policy_evaluations.push(context! {
policy_name => eval.policy_name,
policy_type => eval.policy_type,
passed => eval.passed,
reason => eval.reason,
target_environment => env,
approval_state => approval_state_ctx,
});
}
}
}
break; // Only one active intent per artifact.
}
}
let current_org_entry = orgs.iter().find(|o| o.name == org);
let is_admin = current_org_entry
.map(|o| o.role == "owner" || o.role == "admin")
.unwrap_or(false);
// Build env groups.
let env_groups = build_env_groups(&matching_states);
@@ -1045,6 +1123,10 @@ async fn artifact_detail(
}).collect::<Vec<_>>(),
has_release_intents => release_intents.iter().any(|ri| ri.artifact_id == artifact.artifact_id),
artifact_spec => if artifact_spec.is_empty() { None::<String> } else { Some(artifact_spec) },
policy_evaluations => policy_evaluations,
release_intent_id => &release_intent_id_str,
is_release_author => is_release_author,
is_admin => is_admin,
},
)
.map_err(|e| {
@@ -1965,6 +2047,8 @@ pub enum ApiTimelineItem {
pub struct ApiRelease {
pub artifact_id: String,
pub slug: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub release_intent_id: Option<String>,
pub title: String,
pub description: Option<String>,
pub web: Option<String>,
@@ -2016,6 +2100,8 @@ pub struct ApiPipelineStage {
pub completed_at: Option<String>,
pub error_message: Option<String>,
pub wait_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked_by: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -2033,6 +2119,7 @@ fn build_timeline_json(
deployment_states: &forage_core::platform::DeploymentStates,
release_intents: &[forage_core::platform::ReleaseIntentState],
pipelines_by_project: &PipelinesByProject,
approval_envs: &[String],
) -> ApiTimelineResponse {
// Index destination states by artifact_id.
let mut states_by_artifact: std::collections::HashMap<
@@ -2045,14 +2132,17 @@ fn build_timeline_json(
}
}
// Index pipeline run stages by artifact_id.
// Index pipeline run stages and intent IDs by artifact_id.
let mut intent_stages_by_artifact: std::collections::HashMap<
&str,
&[forage_core::platform::PipelineRunStageState],
> = std::collections::HashMap::new();
let mut intent_id_by_artifact: std::collections::HashMap<&str, &str> =
std::collections::HashMap::new();
for ri in release_intents {
if !ri.stages.is_empty() {
intent_stages_by_artifact.insert(ri.artifact_id.as_str(), &ri.stages);
intent_id_by_artifact.insert(ri.artifact_id.as_str(), ri.release_intent_id.as_str());
}
}
@@ -2189,6 +2279,14 @@ fn build_timeline_json(
} else {
rs.status.clone()
};
let blocked_by = if display_status == "PENDING"
&& rs.stage_type == "deploy"
&& rs.environment.as_deref().map(|e| approval_envs.iter().any(|a| a == e)).unwrap_or(false)
{
Some("Awaiting approval".into())
} else {
None
};
stages.push(ApiPipelineStage {
id: rs.stage_id.clone(),
stage_type: rs.stage_type.clone(),
@@ -2199,6 +2297,7 @@ fn build_timeline_json(
completed_at: rs.completed_at.clone(),
error_message: rs.error_message.clone(),
wait_until: rs.wait_until.clone(),
blocked_by,
});
}
}
@@ -2238,6 +2337,9 @@ fn build_timeline_json(
raw_releases.push(RawRelease {
release: ApiRelease {
release_intent_id: intent_id_by_artifact
.get(artifact.artifact_id.as_str())
.map(|s| s.to_string()),
artifact_id: artifact.artifact_id,
slug: artifact.slug,
title: artifact.context.title,
@@ -2309,7 +2411,8 @@ fn build_timeline_json(
let mut seen_deployed = false;
for raw in raw_releases {
if raw.has_dests {
let needs_action = raw.release.pipeline_stages.iter().any(|s| s.blocked_by.is_some());
if raw.has_dests || needs_action {
if !hidden_buf.is_empty() {
let count = hidden_buf.len();
timeline.push(ApiTimelineItem::Hidden {
@@ -2317,7 +2420,9 @@ fn build_timeline_json(
releases: std::mem::take(&mut hidden_buf),
});
}
seen_deployed = true;
if raw.has_dests {
seen_deployed = true;
}
timeline.push(ApiTimelineItem::Release {
release: Box::new(raw.release),
});
@@ -2358,7 +2463,7 @@ async fn timeline_api(
.into_response());
}
let (artifacts, environments, dest_states, release_intents, project_pipelines) = tokio::join!(
let (artifacts, environments, dest_states, release_intents, project_pipelines, policies) = tokio::join!(
state
.platform_client
.list_artifacts(&session.access_token, &org, &project),
@@ -2374,6 +2479,9 @@ async fn timeline_api(
state
.platform_client
.list_release_pipelines(&session.access_token, &org, &project),
state
.platform_client
.list_policies(&session.access_token, &org, &project),
);
let artifacts = artifacts.map_err(|e| {
tracing::error!("timeline_api list_artifacts: {e:#}");
@@ -2401,7 +2509,18 @@ async fn timeline_api(
pipelines_map.insert(project.clone(), project_pipelines);
}
let data = build_timeline_json(items, &environments, &dest_states, &release_intents, &pipelines_map);
let policies = warn_default("list_policies", policies);
let approval_envs: Vec<String> = policies
.iter()
.filter(|p| p.enabled && p.policy_type == "approval")
.filter_map(|p| match &p.config {
PolicyConfig::Approval { target_environment, .. } => Some(target_environment.clone()),
_ => None,
})
.collect();
let data = build_timeline_json(items, &environments, &dest_states, &release_intents, &pipelines_map, &approval_envs);
Ok(Json(data).into_response())
}
@@ -2463,6 +2582,7 @@ async fn org_timeline_api(
&dest_states,
&release_intents,
&pipelines_by_project,
&[], // org timeline doesn't have per-project policy context
);
Ok(Json(data).into_response())
@@ -3710,6 +3830,16 @@ async fn policies_page(
branch_pattern => branch_pattern,
},
),
PolicyConfig::Approval {
target_environment,
required_approvals,
} => (
"approval",
context! {
target_environment => target_environment,
required_approvals => required_approvals,
},
),
};
context! {
id => p.id,
@@ -3810,6 +3940,9 @@ struct CreatePolicyForm {
// BranchRestriction fields
#[serde(default)]
branch_pattern: String,
// Approval fields
#[serde(default)]
required_approvals: Option<i32>,
}
async fn create_policy_submit(
@@ -3875,12 +4008,28 @@ async fn create_policy_submit(
branch_pattern: pattern.to_string(),
}
}
"approval" => {
let target = form.target_environment.trim();
let required = form.required_approvals.unwrap_or(1);
if target.is_empty() || required < 1 {
return Err(error_page(
&state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Approval requires a target environment and at least 1 required approval.",
));
}
PolicyConfig::Approval {
target_environment: target.to_string(),
required_approvals: required,
}
}
_ => {
return Err(error_page(
&state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Invalid policy type. Must be 'soak_time' or 'branch_restriction'.",
"Invalid policy type.",
));
}
};
@@ -4042,6 +4191,16 @@ async fn edit_policy_page(
branch_pattern => branch_pattern,
},
),
PolicyConfig::Approval {
target_environment,
required_approvals,
} => (
"approval",
context! {
target_environment => target_environment,
required_approvals => required_approvals,
},
),
};
let policy_ctx = context! {
@@ -4464,3 +4623,138 @@ fn non_empty(s: &str) -> Option<String> {
}
}
// ── Approval routes ──────────────────────────────────────────────────
#[derive(Deserialize)]
struct ApprovalForm {
csrf_token: String,
#[serde(default)]
release_intent_id: String,
#[serde(default)]
target_environment: String,
#[serde(default)]
comment: String,
#[serde(default)]
force_bypass: Option<String>,
}
fn approval_error(
state: &AppState,
headers: &axum::http::HeaderMap,
status: StatusCode,
message: &str,
) -> Response {
let wants_json = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("application/json"));
if wants_json {
(status, Json(serde_json::json!({ "error": message }))).into_response()
} else {
error_page(state, status, "Approval failed", message)
}
}
async fn approve_release_submit(
State(state): State<AppState>,
session: Session,
headers: axum::http::HeaderMap,
Path((org, project, slug)): Path<(String, String, String)>,
Form(form): Form<ApprovalForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
if form.csrf_token != session.csrf_token {
return Err(approval_error(
&state,
&headers,
StatusCode::FORBIDDEN,
"CSRF validation failed. Please try again.",
));
}
let force_bypass = form.force_bypass.as_deref() == Some("true");
let comment = non_empty(&form.comment);
state
.platform_client
.approve_release(
&session.access_token,
&org,
&project,
&form.release_intent_id,
&form.target_environment,
comment.as_deref(),
force_bypass,
)
.await
.map_err(|e| match e {
forage_core::platform::PlatformError::NotAuthenticated => {
axum::response::Redirect::to("/login").into_response()
}
other => approval_error(
&state,
&headers,
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{other}"),
),
})?;
Ok(Redirect::to(&format!(
"/orgs/{org}/projects/{project}/releases/{slug}"
))
.into_response())
}
async fn reject_release_submit(
State(state): State<AppState>,
session: Session,
headers: axum::http::HeaderMap,
Path((org, project, slug)): Path<(String, String, String)>,
Form(form): Form<ApprovalForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
if form.csrf_token != session.csrf_token {
return Err(approval_error(
&state,
&headers,
StatusCode::FORBIDDEN,
"CSRF validation failed. Please try again.",
));
}
let comment = non_empty(&form.comment);
state
.platform_client
.reject_release(
&session.access_token,
&org,
&project,
&form.release_intent_id,
&form.target_environment,
comment.as_deref(),
)
.await
.map_err(|e| match e {
forage_core::platform::PlatformError::NotAuthenticated => {
axum::response::Redirect::to("/login").into_response()
}
other => approval_error(
&state,
&headers,
StatusCode::INTERNAL_SERVER_ERROR,
&format!("{other}"),
),
})?;
Ok(Redirect::to(&format!(
"/orgs/{org}/projects/{project}/releases/{slug}"
))
.into_response())
}

View File

@@ -714,6 +714,65 @@ impl ForestPlatform for MockPlatformClient {
.clone()
.unwrap_or(Ok(()))
}
async fn evaluate_policies(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
_target_environment: &str,
_release_intent_id: Option<&str>,
) -> Result<Vec<forage_core::platform::PolicyEvaluation>, PlatformError> {
Ok(vec![])
}
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<forage_core::platform::ApprovalState, PlatformError> {
Ok(forage_core::platform::ApprovalState {
required_approvals: 1,
current_approvals: 1,
decisions: vec![],
})
}
async fn reject_release(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
_release_intent_id: &str,
_target_environment: &str,
_comment: Option<&str>,
) -> Result<forage_core::platform::ApprovalState, PlatformError> {
Ok(forage_core::platform::ApprovalState {
required_approvals: 1,
current_approvals: 0,
decisions: vec![],
})
}
async fn get_approval_state(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
_release_intent_id: &str,
_target_environment: &str,
) -> Result<forage_core::platform::ApprovalState, PlatformError> {
Ok(forage_core::platform::ApprovalState {
required_approvals: 1,
current_approvals: 0,
decisions: vec![],
})
}
}
pub(crate) fn make_templates() -> TemplateEngine {