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

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<meta name="description" content="{{ description }}">
<style>/* Inline critical: cap SVG size before Tailwind loads */svg{max-width:1.5em;max-height:1.5em}</style>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="bg-white text-gray-900 antialiased flex flex-col min-h-screen">

View File

@@ -144,6 +144,97 @@
</div>
{% endif %}
{# ── Policy evaluations (approval, soak, branch) ──────────── #}
{% if policy_evaluations | length > 0 %}
<div class="mb-8">
<h2 class="text-sm font-semibold text-gray-900 mb-3">Policy Requirements</h2>
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
{% for eval in policy_evaluations %}
<div class="px-4 py-3">
<div class="flex items-center gap-3 text-sm">
{% if eval.passed %}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% else %}
<svg class="w-4 h-4 text-amber-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% endif %}
{% if eval.policy_type == "approval" %}
<span class="bg-emerald-100 text-emerald-700 text-xs font-medium px-1.5 py-0.5 rounded">Approval</span>
{% elif eval.policy_type == "soak_time" %}
<span class="bg-indigo-100 text-indigo-700 text-xs font-medium px-1.5 py-0.5 rounded">Soak Time</span>
{% elif eval.policy_type == "branch_restriction" %}
<span class="bg-orange-100 text-orange-700 text-xs font-medium px-1.5 py-0.5 rounded">Branch</span>
{% endif %}
<span class="text-gray-600">{{ eval.policy_name }}</span>
<span class="text-xs text-gray-400 ml-auto">{{ eval.reason }}</span>
</div>
{# ── Approval UI ──────────────────────────────── #}
{% if eval.policy_type == "approval" and eval.approval_state %}
<div class="mt-3 ml-7">
<div class="flex items-center gap-2 text-xs text-gray-500 mb-2">
<span class="font-medium">{{ eval.approval_state.current_approvals }}/{{ eval.approval_state.required_approvals }} approvals</span>
</div>
{% if eval.approval_state.decisions | length > 0 %}
<div class="space-y-1 mb-3">
{% for d in eval.approval_state.decisions %}
<div class="flex items-center gap-2 text-xs">
{% if d.decision == "approved" %}
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
<span class="text-green-700 font-medium">{{ d.username }}</span>
<span class="text-gray-400">approved</span>
{% else %}
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
<span class="text-red-700 font-medium">{{ d.username }}</span>
<span class="text-gray-400">rejected</span>
{% endif %}
{% if d.comment %}<span class="text-gray-400">— {{ d.comment }}</span>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if not eval.passed %}
{% if is_release_author and not is_admin %}
<p class="text-xs text-gray-500 italic">You cannot approve your own release.</p>
{% else %}
<div class="flex items-center gap-2">
{% if not is_release_author %}
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/approve" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors">Approve</button>
</form>
{% endif %}
{% if is_release_author and is_admin %}
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/approve" class="inline" onsubmit="return confirm('You are the release author. This is an admin bypass — are you sure?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
<input type="hidden" name="force_bypass" value="true">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors">Bypass (Admin)</button>
</form>
{% endif %}
<form method="post" action="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/reject" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="release_intent_id" value="{{ release_intent_id }}">
<input type="hidden" name="target_environment" value="{{ eval.target_environment }}">
<button type="submit" class="text-xs px-3 py-1.5 rounded-md border border-red-300 text-red-600 hover:bg-red-50 transition-colors">Reject</button>
</form>
</div>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# ── Destinations with status ──────────────────────────────── #}
{% if destinations | length > 0 or configured_destinations | length > 0 %}
<div class="mb-8">

View File

@@ -7,7 +7,7 @@
<h1 class="text-2xl font-bold">Deployment Policies</h1>
<p class="text-sm text-gray-500 mt-1">
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}" class="hover:underline">{{ current_project }}</a>
&middot; Gate deployments with soak times and branch restrictions
&middot; Gate deployments with soak times, branch restrictions, and approvals
</p>
</div>
</div>
@@ -39,6 +39,11 @@
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.branch_pattern }}</code>
<span class="text-gray-300">&#8594;</span>
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.target_environment }}</code>
{% elif policy.policy_type == "approval" %}
<span class="bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded">Approval Required</span>
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.target_environment }}</code>
<span class="text-gray-400">&middot;</span>
<span>{{ policy.config.required_approvals }} approval{{ 's' if policy.config.required_approvals != 1 }}</span>
{% endif %}
</div>
@@ -68,7 +73,7 @@
<div class="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-500 mb-8">
<p class="mb-1">No deployment policies configured.</p>
{% if is_admin %}
<p class="text-sm">Create one below to gate deployments with soak times or branch restrictions.</p>
<p class="text-sm">Create one below to gate deployments with soak times, branch restrictions, or approvals.</p>
{% endif %}
</div>
{% endif %}
@@ -91,6 +96,7 @@
class="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-900">
<option value="soak_time">Soak Time</option>
<option value="branch_restriction">Branch Restriction</option>
<option value="approval">Approval Required</option>
</select>
<p class="text-xs text-gray-500 mt-1" id="policy-type-desc">
Require an artifact to succeed in a source environment for a duration before deploying to target.
@@ -144,6 +150,26 @@
</div>
</div>
{# Approval fields #}
<div id="approval-fields" class="hidden">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
<select name="target_environment" id="approval-target-env" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
{% for env in environments %}
<option value="{{ env.name }}">{{ env.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Required approvals</label>
<input type="number" name="required_approvals" min="1" value="1" placeholder="1"
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
<p class="text-xs text-gray-500 mt-1">Number of distinct approvals needed before deployment proceeds.</p>
</div>
</div>
</div>
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
Create Policy
</button>
@@ -153,21 +179,33 @@
<script>
(function() {
const typeSelect = document.getElementById('policy-type');
const soakFields = document.getElementById('soak-time-fields');
const branchFields = document.getElementById('branch-restriction-fields');
const sections = {
soak_time: document.getElementById('soak-time-fields'),
branch_restriction: document.getElementById('branch-restriction-fields'),
approval: document.getElementById('approval-fields'),
};
const desc = document.getElementById('policy-type-desc');
const descriptions = {
soak_time: 'Require an artifact to succeed in a source environment for a duration before deploying to target.',
branch_restriction: 'Only allow deployments to the target environment from a specific branch pattern.',
approval: 'Require one or more team members to approve before deploying to the target environment.',
};
typeSelect.addEventListener('change', () => {
const isSoak = typeSelect.value === 'soak_time';
soakFields.classList.toggle('hidden', !isSoak);
branchFields.classList.toggle('hidden', isSoak);
desc.textContent = descriptions[typeSelect.value] || '';
});
function toggle() {
const v = typeSelect.value;
for (const [key, el] of Object.entries(sections)) {
const active = key === v;
el.classList.toggle('hidden', !active);
el.querySelectorAll('input, select').forEach(function(inp) {
inp.disabled = !active;
});
}
desc.textContent = descriptions[v] || '';
}
typeSelect.addEventListener('change', toggle);
toggle();
})();
</script>
{% endif %}

View File

@@ -20,7 +20,7 @@
</div>
</div>
<release-timeline org="{{ org_name }}" project="{{ project_name }}"></release-timeline>
<release-timeline org="{{ org_name }}" project="{{ project_name }}" csrf="{{ csrf_token }}" username="{{ user.username }}" role="{{ current_role }}"></release-timeline>
</section>
<script src="/static/js/components/forage-components.js"></script>
{% endblock %}