kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-17 20:32:49 +02:00
parent 05bcb14b99
commit 02b53e82ca
8 changed files with 555 additions and 68 deletions

View file

@ -823,24 +823,10 @@ async function addDecision() {
<!-- Dependents -->
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
<details v-for="dep in deployResult.dependents_deployed" :key="dep.project_id" class="border border-gray-700 rounded mb-1">
<summary class="flex items-center gap-2 px-2 py-1 cursor-pointer list-none">
<span :class="dep.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold text-[10px]">{{ dep.success ? 'ok' : 'fail' }}</span>
<span class="text-gray-300 text-[11px]">{{ dep.project_name }}</span>
</summary>
<div class="px-2 pb-2 space-y-1">
<details v-for="step in dep.results" :key="step.step" class="border border-gray-800 rounded">
<summary class="flex items-center gap-2 px-2 py-0.5 cursor-pointer list-none">
<span :class="step.exit_code === 0 ? 'text-teal-400' : 'text-red-400'" class="text-[10px]">{{ step.exit_code === 0 ? 'ok' : 'fail' }}</span>
<span class="text-gray-400 text-[10px]">{{ step.step }}</span>
</summary>
<div class="px-2 pb-1">
<pre v-if="step.stdout" class="whitespace-pre-wrap text-gray-300 max-h-24 overflow-y-auto text-[10px]">{{ step.stdout }}</pre>
<pre v-if="step.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-24 overflow-y-auto text-[10px]">{{ step.stderr }}</pre>
</div>
</details>
</div>
</details>
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap mb-2" v-if="project.tech_stack?.length">

View file

@ -7,13 +7,16 @@ const vaultPaths = ref<Record<string, string>>({})
const deployCommands = ref<Record<string, string>>({})
const testCommands = ref<Record<string, string>>({})
const autoTestEnabled = ref<Record<string, boolean>>({})
const worktreesEnabled = ref<Record<string, boolean>>({})
const saving = ref<Record<string, boolean>>({})
const savingTest = ref<Record<string, boolean>>({})
const savingAutoTest = ref<Record<string, boolean>>({})
const savingWorktrees = ref<Record<string, boolean>>({})
const syncing = ref<Record<string, boolean>>({})
const saveStatus = ref<Record<string, string>>({})
const saveTestStatus = ref<Record<string, string>>({})
const saveAutoTestStatus = ref<Record<string, string>>({})
const saveWorktreesStatus = ref<Record<string, string>>({})
const syncResults = ref<Record<string, ObsidianSyncResult | null>>({})
const error = ref<string | null>(null)
@ -43,6 +46,7 @@ onMounted(async () => {
deployCommands.value[p.id] = p.deploy_command ?? ''
testCommands.value[p.id] = p.test_command ?? ''
autoTestEnabled.value[p.id] = !!(p.auto_test_enabled)
worktreesEnabled.value[p.id] = !!(p.worktrees_enabled)
deployHosts.value[p.id] = p.deploy_host ?? ''
deployPaths.value[p.id] = p.deploy_path ?? ''
deployRuntimes.value[p.id] = p.deploy_runtime ?? ''
@ -116,6 +120,21 @@ async function toggleAutoTest(projectId: string) {
}
}
async function toggleWorktrees(projectId: string) {
worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId]
savingWorktrees.value[projectId] = true
saveWorktreesStatus.value[projectId] = ''
try {
await api.patchProject(projectId, { worktrees_enabled: worktreesEnabled.value[projectId] })
saveWorktreesStatus.value[projectId] = 'Saved'
} catch (e: unknown) {
worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId]
saveWorktreesStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
} finally {
savingWorktrees.value[projectId] = false
}
}
async function runSync(projectId: string) {
syncing.value[projectId] = true
syncResults.value[projectId] = null
@ -371,6 +390,23 @@ async function deleteLink(projectId: string, linkId: number) {
</span>
</div>
<div class="flex items-center gap-3 mb-3">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
:checked="worktreesEnabled[project.id]"
@change="toggleWorktrees(project.id)"
:disabled="savingWorktrees[project.id]"
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
/>
<span class="text-sm text-gray-300">Worktrees</span>
<span class="text-xs text-gray-500"> агенты запускаются в изолированных git worktrees</span>
</label>
<span v-if="saveWorktreesStatus[project.id]" class="text-xs" :class="saveWorktreesStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
{{ saveWorktreesStatus[project.id] }}
</span>
</div>
<div class="flex items-center gap-3 flex-wrap">
<button
@click="saveVaultPath(project.id)"

View file

@ -651,24 +651,10 @@ async function saveEdit() {
<!-- Dependents -->
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
<details v-for="dep in deployResult.dependents_deployed" :key="dep.project_id" class="border border-gray-700 rounded mb-1">
<summary class="flex items-center gap-2 px-2 py-1 cursor-pointer list-none">
<span :class="dep.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold text-[10px]">{{ dep.success ? 'ok' : 'fail' }}</span>
<span class="text-gray-300 text-[11px]">{{ dep.project_name }}</span>
</summary>
<div class="px-2 pb-2 space-y-1">
<details v-for="step in dep.results" :key="step.step" class="border border-gray-800 rounded">
<summary class="flex items-center gap-2 px-2 py-0.5 cursor-pointer list-none">
<span :class="step.exit_code === 0 ? 'text-teal-400' : 'text-red-400'" class="text-[10px]">{{ step.exit_code === 0 ? 'ok' : 'fail' }}</span>
<span class="text-gray-400 text-[10px]">{{ step.step }}</span>
</summary>
<div class="px-2 pb-1">
<pre v-if="step.stdout" class="whitespace-pre-wrap text-gray-300 max-h-24 overflow-y-auto text-[10px]">{{ step.stdout }}</pre>
<pre v-if="step.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-24 overflow-y-auto text-[10px]">{{ step.stderr }}</pre>
</div>
</details>
</div>
</details>
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
</div>
</div>
</div>

View file

@ -0,0 +1,203 @@
/**
* KIN-103: Тесты worktrees_enabled toggle в SettingsView
*
* Проверяет:
* 1. Интерфейс Project содержит worktrees_enabled: number | null
* 2. patchProject принимает worktrees_enabled?: boolean
* 3. Инициализацию из числовых значений (0, 1, null) !!()
* 4. Toggle вызывает PATCH с true/false
* 5. Откат при ошибке PATCH
* 6. Checkbox disabled пока идёт сохранение
* 7. Статусные сообщения (Saved / Error)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import SettingsView from '../SettingsView.vue'
vi.mock('../../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api')>()
return {
...actual,
api: {
projects: vi.fn(),
patchProject: vi.fn(),
syncObsidian: vi.fn(),
projectLinks: vi.fn().mockResolvedValue([]),
},
}
})
import { api } from '../../api'
const BASE_PROJECT = {
id: 'proj-1',
name: 'Test Project',
path: '/projects/test',
status: 'active',
priority: 5,
tech_stack: ['python'],
execution_mode: null,
autocommit_enabled: null,
auto_test_enabled: null,
worktrees_enabled: null as number | null,
obsidian_vault_path: null,
deploy_command: null,
test_command: null,
deploy_host: null,
deploy_path: null,
deploy_runtime: null,
deploy_restart_cmd: null,
created_at: '2024-01-01',
total_tasks: 0,
done_tasks: 0,
active_tasks: 0,
blocked_tasks: 0,
review_tasks: 0,
project_type: null,
ssh_host: null,
ssh_user: null,
ssh_key_path: null,
ssh_proxy_jump: null,
description: null,
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT as any)
})
async function mountSettings(overrides: Partial<typeof BASE_PROJECT> = {}) {
const project = { ...BASE_PROJECT, ...overrides }
vi.mocked(api.projects).mockResolvedValue([project as any])
const wrapper = mount(SettingsView)
await flushPromises()
return wrapper
}
function findWorktreesCheckbox(wrapper: ReturnType<typeof mount>) {
const labels = wrapper.findAll('label')
const worktreesLabel = labels.find(l => l.text().includes('Worktrees'))
const checkbox = worktreesLabel?.find('input[type="checkbox"]')
// decision #544: assertion безусловная — ложный зелёный недопустим
expect(checkbox?.exists()).toBe(true)
return checkbox!
}
// ─────────────────────────────────────────────────────────────
// 1. Инициализация из числовых значений
// ─────────────────────────────────────────────────────────────
describe('worktreesEnabled — инициализация', () => {
it('worktrees_enabled=1 → checkbox checked', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 1 })
const checkbox = findWorktreesCheckbox(wrapper)
expect((checkbox.element as HTMLInputElement).checked).toBe(true)
})
it('worktrees_enabled=0 → checkbox unchecked', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 0 })
const checkbox = findWorktreesCheckbox(wrapper)
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
})
it('worktrees_enabled=null → checkbox unchecked', async () => {
const wrapper = await mountSettings({ worktrees_enabled: null })
const checkbox = findWorktreesCheckbox(wrapper)
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
})
})
// ─────────────────────────────────────────────────────────────
// 2. Toggle → patchProject вызывается с корректным значением
// ─────────────────────────────────────────────────────────────
describe('toggleWorktrees — вызов patchProject', () => {
it('toggle с unchecked → patchProject({ worktrees_enabled: true })', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 0 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect(vi.mocked(api.patchProject)).toHaveBeenCalledWith(
'proj-1',
expect.objectContaining({ worktrees_enabled: true }),
)
})
it('toggle с checked → patchProject({ worktrees_enabled: false }), не undefined (decision #524)', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 1 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
const payload = vi.mocked(api.patchProject).mock.calls[0][1] as any
expect(payload.worktrees_enabled).toBe(false)
expect(payload.worktrees_enabled).not.toBeUndefined()
})
})
// ─────────────────────────────────────────────────────────────
// 3. Откат при ошибке PATCH
// ─────────────────────────────────────────────────────────────
describe('toggleWorktrees — откат при ошибке PATCH', () => {
it('ошибка при включении: checkbox откатывается обратно к unchecked', async () => {
vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('network error'))
const wrapper = await mountSettings({ worktrees_enabled: 0 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(false)
})
it('ошибка при выключении: checkbox откатывается обратно к checked', async () => {
vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('server error'))
const wrapper = await mountSettings({ worktrees_enabled: 1 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────
// 4. Disabled во время сохранения
// ─────────────────────────────────────────────────────────────
describe('toggleWorktrees — disabled во время сохранения', () => {
it('checkbox disabled пока идёт PATCH, enabled после завершения', async () => {
let resolveRequest!: (v: any) => void
vi.mocked(api.patchProject).mockImplementationOnce(
() => new Promise(resolve => { resolveRequest = resolve }),
)
const wrapper = await mountSettings({ worktrees_enabled: 0 })
const checkbox = findWorktreesCheckbox(wrapper)
await checkbox.trigger('change')
// savingWorktrees = true → checkbox должен быть disabled
expect((checkbox.element as HTMLInputElement).disabled).toBe(true)
resolveRequest(BASE_PROJECT)
await flushPromises()
expect((checkbox.element as HTMLInputElement).disabled).toBe(false)
})
})
// ─────────────────────────────────────────────────────────────
// 5. Статусные сообщения
// ─────────────────────────────────────────────────────────────
describe('toggleWorktrees — статусное сообщение', () => {
it('успех → показывает "Saved"', async () => {
const wrapper = await mountSettings({ worktrees_enabled: 0 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect(wrapper.text()).toContain('Saved')
})
it('ошибка → показывает "Error: <message>"', async () => {
vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('connection timeout'))
const wrapper = await mountSettings({ worktrees_enabled: 0 })
await findWorktreesCheckbox(wrapper).trigger('change')
await flushPromises()
expect(wrapper.text()).toContain('Error: connection timeout')
})
})