@@ -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
@@ -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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 => ¤t_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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user