feat: add plan step

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-15 22:38:18 +01:00
parent 7eb6ae7cbb
commit 04e452ecc3
71 changed files with 1059 additions and 319 deletions

View File

@@ -125,6 +125,18 @@ pub fn router() -> Router<AppState> {
post(delete_pipeline),
)
.route("/users/{username}", get(user_profile))
.route(
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/approve",
post(approve_plan_stage_submit),
)
.route(
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/reject",
post(reject_plan_stage_submit),
)
.route(
"/api/orgs/{org}/projects/{project}/plan-stages/{stage_id}/output",
get(get_plan_output_api),
)
.route(
"/api/orgs/{org}/projects/{project}/timeline",
get(timeline_api),
@@ -402,7 +414,12 @@ async fn fetch_notifications(
if let Some(run_stages) = intent_stages_by_artifact.get(aid) {
let sorted = topo_sort_run_stages(run_stages);
for rs in sorted {
let display_status = deploy_stage_display_status(rs, &matching_states);
let base_status = deploy_stage_display_status(rs, &matching_states);
let display_status = if rs.stage_type == "plan" && rs.approval_status.as_deref() == Some("AWAITING_APPROVAL") {
"AWAITING_APPROVAL"
} else {
base_status
};
pipeline_stages.push(context! {
id => rs.stage_id,
stage_type => rs.stage_type,
@@ -413,6 +430,7 @@ async fn fetch_notifications(
completed_at => rs.completed_at,
error_message => rs.error_message,
wait_until => rs.wait_until,
approval_status => rs.approval_status,
});
}
}
@@ -895,7 +913,7 @@ async fn artifact_detail(
));
}
let (artifact_result, projects, dest_states, release_intents, pipelines) = tokio::join!(
let (artifact_result, projects, dest_states, release_intents, pipelines, environments) = tokio::join!(
state
.platform_client
.get_artifact_by_slug(&session.access_token, &slug),
@@ -911,6 +929,9 @@ async fn artifact_detail(
state
.platform_client
.list_release_pipelines(&session.access_token, &org, &project),
state
.platform_client
.list_environments(&session.access_token, &org),
);
// Fetch artifact spec after we have the artifact_id (needs artifact_result first).
@@ -954,44 +975,62 @@ async fn artifact_detail(
.any(|ri| ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty())
});
// Build pipeline stages from intent data.
// Build pipeline stages from the most recent release intent for this artifact.
let mut pipeline_stages: Vec<minijinja::Value> = Vec::new();
for ri in &release_intents {
if ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty() {
let sorted = topo_sort_run_stages(&ri.stages);
for rs in sorted {
let display_status = deploy_stage_display_status(rs, &matching_states);
pipeline_stages.push(context! {
id => rs.stage_id,
stage_type => rs.stage_type,
environment => rs.environment,
duration_seconds => rs.duration_seconds,
status => display_status,
started_at => rs.started_at,
completed_at => rs.completed_at,
error_message => rs.error_message,
wait_until => rs.wait_until,
});
}
let latest_intent = release_intents
.iter()
.filter(|ri| ri.artifact_id == artifact.artifact_id && !ri.stages.is_empty())
.max_by_key(|ri| &ri.created_at);
if let Some(ri) = latest_intent {
let sorted = topo_sort_run_stages(&ri.stages);
for rs in sorted {
let base_status = deploy_stage_display_status(rs, &matching_states);
let display_status = if rs.stage_type == "plan" && rs.approval_status.as_deref() == Some("AWAITING_APPROVAL") {
"AWAITING_APPROVAL"
} else {
base_status
};
pipeline_stages.push(context! {
id => rs.stage_id,
stage_type => rs.stage_type,
environment => rs.environment,
duration_seconds => rs.duration_seconds,
status => display_status,
started_at => rs.started_at,
completed_at => rs.completed_at,
error_message => rs.error_message,
wait_until => rs.wait_until,
approval_status => rs.approval_status,
});
}
}
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();
struct PolicyEvalEntry {
policy_name: String,
policy_type: String,
passed: bool,
reason: String,
target_environment: String,
approval_state: Option<forage_core::platform::ApprovalState>,
}
let mut raw_evals: Vec<PolicyEvalEntry> = Vec::new();
let release_intent_id_str = latest_intent
.map(|ri| ri.release_intent_id.clone())
.unwrap_or_default();
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.
if let Some(ri) = latest_intent {
{
let mut seen = std::collections::BTreeSet::new();
let environments: Vec<String> = ri
.stages
.iter()
.filter_map(|s| s.environment.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.filter(|e| seen.insert(e.clone()))
.collect();
for env in &environments {
@@ -1007,40 +1046,55 @@ async fn artifact_detail(
.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,
raw_evals.push(PolicyEvalEntry {
policy_name: eval.policy_name,
policy_type: eval.policy_type,
passed: eval.passed,
reason: eval.reason,
target_environment: env.clone(),
approval_state: eval.approval_state,
});
}
}
}
break; // Only one active intent per artifact.
}
}
raw_evals.sort_by(|a, b| a.policy_type.cmp(&b.policy_type).then(a.policy_name.cmp(&b.policy_name)));
let policy_evaluations: Vec<minijinja::Value> = raw_evals
.iter()
.map(|eval| {
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,
}
});
context! {
policy_name => eval.policy_name,
policy_type => eval.policy_type,
passed => eval.passed,
reason => eval.reason,
target_environment => eval.target_environment,
approval_state => approval_state_ctx,
}
})
.collect();
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")
@@ -1066,6 +1120,8 @@ async fn artifact_detail(
})
.collect();
let artifact_id_val = artifact.artifact_id.clone();
let html = state
.templates
.render(
@@ -1127,6 +1183,12 @@ async fn artifact_detail(
release_intent_id => &release_intent_id_str,
is_release_author => is_release_author,
is_admin => is_admin,
artifact_id => &artifact_id_val,
has_active_pipeline => has_pipeline,
environments => warn_default("list_environments", environments)
.iter()
.map(|e| context! { name => e.name })
.collect::<Vec<_>>(),
},
)
.map_err(|e| {
@@ -2102,6 +2164,10 @@ pub struct ApiPipelineStage {
pub wait_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
}
#[derive(Debug, Serialize)]
@@ -2287,6 +2353,14 @@ fn build_timeline_json(
} else {
None
};
// For plan stages, use AWAITING_APPROVAL as display status when appropriate
let display_status = if rs.stage_type == "plan"
&& rs.approval_status.as_deref() == Some("AWAITING_APPROVAL")
{
"AWAITING_APPROVAL".to_string()
} else {
display_status
};
stages.push(ApiPipelineStage {
id: rs.stage_id.clone(),
stage_type: rs.stage_type.clone(),
@@ -2298,6 +2372,8 @@ fn build_timeline_json(
error_message: rs.error_message.clone(),
wait_until: rs.wait_until.clone(),
blocked_by,
approval_status: rs.approval_status.clone(),
auto_approve: rs.auto_approve,
});
}
}
@@ -2411,7 +2487,10 @@ fn build_timeline_json(
let mut seen_deployed = false;
for raw in raw_releases {
let needs_action = raw.release.pipeline_stages.iter().any(|s| s.blocked_by.is_some());
let needs_action = raw.release.pipeline_stages.iter().any(|s| {
s.blocked_by.is_some()
|| (s.stage_type == "plan" && s.status == "AWAITING_APPROVAL")
});
if raw.has_dests || needs_action {
if !hidden_buf.is_empty() {
let count = hidden_buf.len();
@@ -4758,3 +4837,155 @@ async fn reject_release_submit(
.into_response())
}
// ── Plan stage approve / reject / output ─────────────────────────────
#[derive(Deserialize)]
struct PlanStageForm {
csrf_token: String,
release_intent_id: String,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
redirect_to: Option<String>,
}
async fn approve_plan_stage_submit(
State(state): State<AppState>,
session: Session,
headers: axum::http::HeaderMap,
Path((org, _project, stage_id)): Path<(String, String, String)>,
Form(form): Form<PlanStageForm>,
) -> 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.",
));
}
state
.platform_client
.approve_plan_stage(
&session.access_token,
&form.release_intent_id,
&stage_id,
)
.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}"),
),
})?;
if let Some(redirect) = &form.redirect_to {
Ok(Redirect::to(redirect).into_response())
} else {
Ok(Json(serde_json::json!({ "ok": true })).into_response())
}
}
async fn reject_plan_stage_submit(
State(state): State<AppState>,
session: Session,
headers: axum::http::HeaderMap,
Path((org, _project, stage_id)): Path<(String, String, String)>,
Form(form): Form<PlanStageForm>,
) -> 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 reason = form.reason.as_deref().and_then(|s| {
let t = s.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
});
state
.platform_client
.reject_plan_stage(
&session.access_token,
&form.release_intent_id,
&stage_id,
reason.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}"),
),
})?;
if let Some(redirect) = &form.redirect_to {
Ok(Redirect::to(redirect).into_response())
} else {
Ok(Json(serde_json::json!({ "ok": true })).into_response())
}
}
#[derive(Deserialize)]
struct PlanOutputQuery {
release_intent_id: String,
}
async fn get_plan_output_api(
State(state): State<AppState>,
session: Session,
Path((org, _project, stage_id)): Path<(String, String, String)>,
Query(query): Query<PlanOutputQuery>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
let output = state
.platform_client
.get_plan_output(
&session.access_token,
&query.release_intent_id,
&stage_id,
)
.await
.map_err(|e| {
internal_error(&state, "get plan output", &e)
})?;
let outputs: Vec<serde_json::Value> = output.outputs.iter().map(|o| {
serde_json::json!({
"destination_id": o.destination_id,
"destination_name": o.destination_name,
"plan_output": o.plan_output,
"status": o.status,
})
}).collect();
Ok(Json(serde_json::json!({
"plan_output": output.plan_output,
"status": output.status,
"outputs": outputs,
}))
.into_response())
}