kin: KIN-049 Кнопка Deploy на странице задачи после approve. Для каждого проекта настраивается deploy-команда (git push, scp, ssh restart). В Settings проекта.

This commit is contained in:
Gros Frumos 2026-03-16 08:21:13 +02:00
parent 860ef3f6c9
commit d50bd703ae
11 changed files with 517 additions and 61 deletions

View file

@ -28,6 +28,7 @@ vi.mock('../api', () => ({
createTask: vi.fn(),
patchTask: vi.fn(),
patchProject: vi.fn(),
deployProject: vi.fn(),
},
}))
@ -785,3 +786,126 @@ describe('KIN-015: TaskDetail — Edit button и форма редактиров
expect(wrapper.find('input:not([type])').exists(), 'Форма должна закрыться после сохранения').toBe(false)
})
})
// ─────────────────────────────────────────────────────────────
// KIN-049: TaskDetail — кнопка Deploy
// ─────────────────────────────────────────────────────────────
describe('KIN-049: TaskDetail — кнопка Deploy', () => {
function makeDeployTask(status: string, deployCommand: string | null) {
return {
id: 'KIN-049',
project_id: 'KIN',
title: 'Deploy Task',
status,
priority: 3,
assigned_role: null,
parent_task_id: null,
brief: null,
spec: null,
execution_mode: null,
project_deploy_command: deployCommand,
created_at: '2024-01-01',
updated_at: '2024-01-01',
pipeline_steps: [],
related_decisions: [],
}
}
it('Кнопка Deploy видна при status=done и project_deploy_command задан', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('done', 'git push origin main') as any)
const router = makeRouter()
await router.push('/task/KIN-049')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-049' },
global: { plugins: [router] },
})
await flushPromises()
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
expect(deployBtn?.exists(), 'Кнопка Deploy должна быть видна при done + deploy_command').toBe(true)
})
it('Кнопка Deploy скрыта при status=done но без project_deploy_command', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('done', null) as any)
const router = makeRouter()
await router.push('/task/KIN-049')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-049' },
global: { plugins: [router] },
})
await flushPromises()
const hasDeployBtn = wrapper.findAll('button').some(b => b.text().includes('Deploy'))
expect(hasDeployBtn, 'Deploy не должна быть видна без deploy_command').toBe(false)
})
it('Кнопка Deploy скрыта при status=pending (даже с deploy_command)', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('pending', 'git push') as any)
const router = makeRouter()
await router.push('/task/KIN-049')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-049' },
global: { plugins: [router] },
})
await flushPromises()
const hasDeployBtn = wrapper.findAll('button').some(b => b.text().includes('Deploy'))
expect(hasDeployBtn, 'Deploy не должна быть видна при статусе pending').toBe(false)
})
it('Кнопка Deploy скрыта при status=in_progress', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('in_progress', 'git push') as any)
const router = makeRouter()
await router.push('/task/KIN-049')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-049' },
global: { plugins: [router] },
})
await flushPromises()
const hasDeployBtn = wrapper.findAll('button').some(b => b.text().includes('Deploy'))
expect(hasDeployBtn, 'Deploy не должна быть видна при статусе in_progress').toBe(false)
})
it('Кнопка Deploy скрыта при status=review', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('review', 'git push') as any)
const router = makeRouter()
await router.push('/task/KIN-049')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-049' },
global: { plugins: [router] },
})
await flushPromises()
const hasDeployBtn = wrapper.findAll('button').some(b => b.text().includes('Deploy'))
expect(hasDeployBtn, 'Deploy не должна быть видна при статусе review').toBe(false)
})
it('Клик по Deploy вызывает api.deployProject с project_id задачи', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('done', 'echo ok') as any)
vi.mocked(api.deployProject).mockResolvedValue({
success: true, exit_code: 0, stdout: 'ok\n', stderr: '', duration_seconds: 0.1,
} as any)
const router = makeRouter()
await router.push('/task/KIN-049')
const wrapper = mount(TaskDetail, {
props: { id: 'KIN-049' },
global: { plugins: [router] },
})
await flushPromises()
const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
await deployBtn!.trigger('click')
await flushPromises()
expect(api.deployProject).toHaveBeenCalledWith('KIN')
})
})

View file

@ -42,6 +42,7 @@ export interface Project {
execution_mode: string | null
autocommit_enabled: number | null
obsidian_vault_path: string | null
deploy_command: string | null
created_at: string
total_tasks: number
done_tasks: number
@ -76,6 +77,7 @@ export interface Task {
execution_mode: string | null
blocked_reason: string | null
dangerously_skipped: number | null
category: string | null
created_at: string
updated_at: string
}
@ -116,9 +118,18 @@ export interface PipelineStep {
created_at: string
}
export interface DeployResult {
success: boolean
exit_code: number
stdout: string
stderr: string
duration_seconds: number
}
export interface TaskFull extends Task {
pipeline_steps: PipelineStep[]
related_decisions: Decision[]
project_deploy_command: string | null
}
export interface PendingAction {
@ -161,7 +172,7 @@ export const api = {
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) =>
post<Project>('/projects', data),
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) =>
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string; category?: string }) =>
post<Task>('/tasks', data),
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string; create_followups?: boolean }) =>
post<{ status: string; followup_tasks: Task[]; needs_decision: boolean; pending_actions: PendingAction[] }>(`/tasks/${id}/approve`, data || {}),
@ -181,8 +192,10 @@ export const api = {
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string }) =>
patch<Task>(`/tasks/${id}`, data),
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; obsidian_vault_path?: string }) =>
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string }) =>
patch<Project>(`/projects/${id}`, data),
deployProject: (projectId: string) =>
post<DeployResult>(`/projects/${projectId}/deploy`, {}),
syncObsidian: (projectId: string) =>
post<ObsidianSyncResult>(`/projects/${projectId}/sync/obsidian`, {}),
deleteDecision: (projectId: string, decisionId: number) =>

View file

@ -4,9 +4,12 @@ import { api, type Project, type ObsidianSyncResult } from '../api'
const projects = ref<Project[]>([])
const vaultPaths = ref<Record<string, string>>({})
const deployCommands = ref<Record<string, string>>({})
const saving = ref<Record<string, boolean>>({})
const savingDeploy = ref<Record<string, boolean>>({})
const syncing = ref<Record<string, boolean>>({})
const saveStatus = ref<Record<string, string>>({})
const saveDeployStatus = ref<Record<string, string>>({})
const syncResults = ref<Record<string, ObsidianSyncResult | null>>({})
const error = ref<string | null>(null)
@ -15,6 +18,7 @@ onMounted(async () => {
projects.value = await api.projects()
for (const p of projects.value) {
vaultPaths.value[p.id] = p.obsidian_vault_path ?? ''
deployCommands.value[p.id] = p.deploy_command ?? ''
}
} catch (e) {
error.value = String(e)
@ -34,6 +38,19 @@ async function saveVaultPath(projectId: string) {
}
}
async function saveDeployCommand(projectId: string) {
savingDeploy.value[projectId] = true
saveDeployStatus.value[projectId] = ''
try {
await api.patchProject(projectId, { deploy_command: deployCommands.value[projectId] })
saveDeployStatus.value[projectId] = 'Saved'
} catch (e) {
saveDeployStatus.value[projectId] = `Error: ${e}`
} finally {
savingDeploy.value[projectId] = false
}
}
async function runSync(projectId: string) {
syncing.value[projectId] = true
syncResults.value[projectId] = null
@ -70,13 +87,37 @@ async function runSync(projectId: string) {
/>
</div>
<div class="mb-3">
<label class="block text-xs text-gray-400 mb-1">Deploy Command</label>
<input
v-model="deployCommands[project.id]"
type="text"
placeholder="git push origin main"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500"
/>
<p class="text-xs text-gray-600 mt-1">Команда выполняется через shell в директории проекта. Настраивается только администратором.</p>
</div>
<div class="flex items-center gap-3 flex-wrap mb-3">
<button
@click="saveDeployCommand(project.id)"
:disabled="savingDeploy[project.id]"
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
>
{{ savingDeploy[project.id] ? 'Saving…' : 'Save Deploy' }}
</button>
<span v-if="saveDeployStatus[project.id]" class="text-xs" :class="saveDeployStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
{{ saveDeployStatus[project.id] }}
</span>
</div>
<div class="flex items-center gap-3 flex-wrap">
<button
@click="saveVaultPath(project.id)"
:disabled="saving[project.id]"
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
>
{{ saving[project.id] ? 'Saving…' : 'Save' }}
{{ saving[project.id] ? 'Saving…' : 'Save Vault' }}
</button>
<button

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api'
import { api, type TaskFull, type PipelineStep, type PendingAction, type DeployResult } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
@ -262,6 +262,23 @@ async function changeStatus(newStatus: string) {
}
}
// Deploy
const deploying = ref(false)
const deployResult = ref<DeployResult | null>(null)
async function runDeploy() {
if (!task.value) return
deploying.value = true
deployResult.value = null
try {
deployResult.value = await api.deployProject(task.value.project_id)
} catch (e: any) {
error.value = e.message
} finally {
deploying.value = false
}
}
// Edit modal (pending tasks only)
const showEdit = ref(false)
const editForm = ref({ title: '', briefText: '', priority: 5 })
@ -488,6 +505,27 @@ async function saveEdit() {
<span v-if="resolvingManually" class="inline-block w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ resolvingManually ? 'Сохраняем...' : '&#10003; Решить вручную' }}
</button>
<button v-if="task.status === 'done' && task.project_deploy_command"
@click.stop="runDeploy"
:disabled="deploying"
class="px-4 py-2 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50">
<span v-if="deploying" class="inline-block w-3 h-3 border-2 border-teal-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ deploying ? 'Deploying...' : '&#x1F680; Deploy' }}
</button>
</div>
<!-- Deploy result inline block -->
<div v-if="deployResult" class="mx-0 mt-2 p-3 rounded border text-xs font-mono"
:class="deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
<div class="flex items-center gap-2 mb-1">
<span :class="deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
{{ deployResult.success ? '✓ Deploy succeeded' : '✗ Deploy failed' }}
</span>
<span class="text-gray-500">exit {{ deployResult.exit_code }} · {{ deployResult.duration_seconds }}s</span>
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs"></button>
</div>
<pre v-if="deployResult.stdout" class="whitespace-pre-wrap text-gray-300 max-h-40 overflow-y-auto">{{ deployResult.stdout }}</pre>
<pre v-if="deployResult.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-40 overflow-y-auto mt-1">{{ deployResult.stderr }}</pre>
</div>
<!-- Approve Modal -->