diff --git a/mock/README.md b/mock/README.md index 8350ac3..8dca0e1 100644 --- a/mock/README.md +++ b/mock/README.md @@ -19,8 +19,11 @@ What to review: then choose a role. - The preview is intentionally the **user view only**: no admin page, no superadmin team console and no workspace proxy controls. -- Job lifecycle: run iterate/plan/review, open the job detail, and execute a - completed plan. +- Work items: create, triage, assign, start, and move through the state machine. +- Job lifecycle: run iterate/plan/review, open the job detail, pause/cancel/resume, + approve a plan, and execute an approved plan. +- Repository settings: plan-first, allowed/forbidden paths, merge method and + branch-protection requirement. - Global settings and the mock question inbox. - The top-right `中 / EN` switch changes the main labels; mock content stays as written. diff --git a/mock/fledge-ui.html b/mock/fledge-ui.html index 3524151..5a31405 100644 --- a/mock/fledge-ui.html +++ b/mock/fledge-ui.html @@ -229,8 +229,19 @@ ], jobs: [ { id: 'job_o6mO', kind: 'iterate', status: 'succeeded', trigger: 'schedule', summary: '修 #499 上评审 F1:balancedParenTail() 回接括号组后直接 return,带括号段的地址被截断;补 6 行组间扫描 + 回归测试。', pr: '#499', issue: '#498', cost: '$0.42', created: '2026-11-03 10:22', evidence: { tests: '36/36 pass', checks: 'CI 两绿', files: 6, risk: 'low', rollback: 'revert commit' }, attribution: 'Lead · Dev-1 · Test · Reviewer' }, - { id: 'job_7c2a', kind: 'plan', status: 'succeeded', trigger: 'manual', summary: '计划:把地址解析的括号处理抽成独立函数,补 3 个回归用例,不改变现有输出。', pr: '—', issue: '—', cost: '$0.03', created: '2026-11-02 18:10', evidence: { tests: 'not run', checks: 'not run', files: 0, risk: 'low', rollback: 'no change' }, attribution: 'Product · Test' } - ] + { id: 'job_7c2a', kind: 'plan', status: 'succeeded', trigger: 'manual', summary: '计划:把地址解析的括号处理抽成独立函数,补 3 个回归用例,不改变现有输出。', pr: '—', issue: '—', cost: '$0.03', created: '2026-11-02 18:10', evidence: { tests: 'not run', checks: 'not run', files: 0, risk: 'low', rollback: 'no change' }, attribution: 'Product · Test', planApproved: false } + ], + workItems: [ + { id: 'work_dm1', title: '修复地址括号回接', state: 'pr_ready', priority: 30, pr: '#499', issue: '#498', job: 'job_o6mO' }, + { id: 'work_dm2', title: '对比竞品记忆能力并提需求', state: 'proposed', priority: 20, pr: '—', issue: '—', job: '—' } + ], + planRequired: false, + allowPaths: 'src/', + denyPaths: '.github/', + mergeMethod: 'squash', + requireProtection: true, + protectionState: 'protected', + protectedAt: '2026-11-04 09:10' }, fledge: { id: 'fledge', @@ -253,8 +264,20 @@ questions: [], jobs: [ { id: 'job_rel10', kind: 'review', status: 'succeeded', trigger: 'schedule', summary: 'Reviewed 6 PRs; average 82. Two Fledge PRs need changes; follow-ups queued on the same branches.', pr: '—', issue: '—', cost: '$0.18', created: '2026-11-03 09:40', evidence: { tests: 'not run', checks: 'CI green', files: 0, risk: 'low', rollback: 'no change' }, attribution: 'Reviewer' }, - { id: 'job_v022', kind: 'iterate', status: 'succeeded', trigger: 'manual', summary: 'release: v0.2.2 — assembled 16 changelog fragments, bumped package/healthz version and updated release references.', pr: '#11', issue: '—', cost: '$0.09', created: '2026-11-03 10:18', evidence: { tests: '86/86 pass', checks: 'CI green', files: 27, risk: 'low', rollback: 'revert release commit' }, attribution: 'Release' } - ] + { id: 'job_v022', kind: 'iterate', status: 'succeeded', trigger: 'manual', summary: 'release: v0.2.2 — assembled 16 changelog fragments, bumped package/healthz version and updated release references.', pr: '#11', issue: '—', cost: '$0.09', created: '2026-11-03 10:18', evidence: { tests: '86/86 pass', checks: 'CI green', files: 27, risk: 'low', rollback: 'revert release commit' }, attribution: 'Release' }, + { id: 'job_plan_p0', kind: 'plan', status: 'succeeded', trigger: 'manual', summary: 'Plan: add work-item state transitions, a plan approval gate and branch-protection verification. Touch src/ and docs/ only.', pr: '—', issue: '—', cost: '$0.02', created: '2026-11-04 09:30', evidence: { tests: 'not run', checks: 'not run', files: 0, risk: 'low', rollback: 'no change' }, attribution: 'Product · Test', planApproved: false } + ], + workItems: [ + { id: 'work_fl1', title: 'P0: work items 与状态机', state: 'merged', priority: 50, pr: '#12', issue: '—', job: 'job_v022' }, + { id: 'work_fl2', title: '补充 branch protection 校验', state: 'in_review', priority: 40, pr: '#13', issue: '—', job: 'job_rel10' } + ], + planRequired: true, + allowPaths: 'src/, docs/', + denyPaths: '.github/', + mergeMethod: 'squash', + requireProtection: true, + protectionState: 'protected', + protectedAt: '2026-11-04 09:20' }, widgets: { id: 'widgets', @@ -272,7 +295,15 @@ instance: 'starting', chief: [], questions: [], - jobs: [] + jobs: [], + workItems: [], + planRequired: false, + allowPaths: '', + denyPaths: '', + mergeMethod: 'squash', + requireProtection: true, + protectionState: 'unknown', + protectedAt: null } }, }; @@ -284,10 +315,13 @@ const allowedRoles = (permission) => permission === 'admin' || permission === 'maintain' ? ['maintainer', 'developer'] : ['developer']; const roleLabel = (role) => role === 'maintainer' ? t('维护者', 'Maintainer') : role === 'developer' ? t('开发者', 'Developer') : t('未选择', 'Not selected'); const roleClass = (role) => role === 'maintainer' ? 'role-m' : role === 'developer' ? 'role-d' : ''; +const workStateLabel = (state) => ({ proposed: t('待分诊', 'Proposed'), triaged: t('已分诊', 'Triaged'), assigned: t('已分配', 'Assigned'), in_progress: t('进行中', 'In progress'), pr_ready: t('PR 已就绪', 'PR ready'), in_review: t('审核中', 'In review'), changes_requested: t('需要修改', 'Changes requested'), approved: t('已批准', 'Approved'), merged: t('已合并', 'Merged'), closed: t('已关闭', 'Closed') }[state] || state); +const workStateClass = (state) => state === 'merged' || state === 'approved' ? 'good' : state === 'closed' ? 'bad' : state === 'in_progress' || state === 'in_review' || state === 'pr_ready' || state === 'changes_requested' ? 'busy' : 'kind'; +const workNextStates = (state) => ({ proposed: ['triaged', 'closed'], triaged: ['assigned', 'closed'], assigned: ['in_progress', 'triaged', 'closed'], in_progress: ['pr_ready', 'changes_requested', 'closed'], pr_ready: ['in_review', 'changes_requested', 'merged', 'closed'], in_review: ['changes_requested', 'approved', 'merged', 'closed'], changes_requested: ['in_progress', 'closed'], approved: ['merged', 'closed'], merged: [], closed: ['triaged'] }[state] || []); const permLabel = (permission) => ({ admin: 'admin', maintain: 'maintain', write: 'write', triage: 'triage', read: 'read' }[permission] || permission); const statusBadge = (status) => { - const cls = status === 'succeeded' || status === 'approved' ? 'good' : status === 'failed' || status === 'rejected' ? 'bad' : status === 'running' || status === 'queued' || status === 'pending' ? 'busy' : ''; - const label = { succeeded: t('成功', 'Succeeded'), failed: t('失败', 'Failed'), running: t('运行中', 'Running'), queued: t('排队中', 'Queued'), approved: t('已批准', 'Approved'), pending: t('待审批', 'Pending'), idle: t('空闲', 'Idle') }[status] || status; + const cls = status === 'succeeded' || status === 'approved' ? 'good' : status === 'failed' || status === 'rejected' || status === 'cancelled' ? 'bad' : status === 'running' || status === 'queued' || status === 'pending' || status === 'paused' ? 'busy' : ''; + const label = { succeeded: t('成功', 'Succeeded'), failed: t('失败', 'Failed'), running: t('运行中', 'Running'), queued: t('排队中', 'Queued'), paused: t('已暂停', 'Paused'), cancelled: t('已取消', 'Cancelled'), approved: t('已批准', 'Approved'), pending: t('待审批', 'Pending'), idle: t('空闲', 'Idle') }[status] || status; return `${esc(label)}`; }; const kindLabel = (kind) => kind === 'review' ? t('审核', 'Review') : kind === 'plan' ? t('计划', 'Plan') : t('迭代', 'Iterate'); @@ -491,6 +525,25 @@

${esc(t('选择你在这个仓库的角色', 'Choose your role for this repo
${esc(t('每仓库 FlyBot', 'PER-REPOSITORY FLYBOT'))}

${esc(t('FlyBot 状态', 'FlyBot status'))}

${repo.instance === 'ready' ? statusBadge('ready') : statusBadge('pending')} ${esc(t('实例', 'Instance'))} repo-${esc(repo.id)}

${esc(t('任务先发给 Chief,再由负责人执行。', 'Jobs go to the Chief, then the Lead.'))}

`; + const workItems = repo.workItems || []; + const workRows = workItems.length === 0 + ? `${esc(t('还没有工作项。', 'No work items yet.'))}` + : workItems.map((item) => ` + ${esc(item.title)} + ${esc(workStateLabel(item.state))} + ${item.priority} + ${item.issue === '—' ? '' : `${esc(item.issue)}`}${item.pr === '—' ? '' : ` ${esc(item.pr)}`} +
${item.state === 'merged' || item.state === 'closed' ? '' : ``}${workNextStates(item.state).length === 0 ? '' : ``}
+ `).join(''); + const workCard = `
+

${esc(t('工作项', 'Work items'))}

${esc(t('Issue / PR / 状态 / 优先级', 'Issue / PR / state / priority'))}
+
+
+ + +
+
${workRows}
${esc(t('工作项', 'Work item'))}${esc(t('状态', 'State'))}${esc(t('优先级', 'Priority'))}Issue / PR
+
`; const settingsCard = `

${esc(t('迭代目标', 'Iteration goal'))}

${esc(roleLabel(repo.role))}
@@ -502,6 +555,10 @@

${esc(t('选择你在这个仓库的角色', 'Choose your role for this repo + +
+
+

${esc(t('Branch protection', 'Branch protection'))}: ${esc(repo.protectionState || 'unknown')}${repo.protectedAt ? ` · ${esc(repo.protectedAt)}` : ''}

${repo.role === null ? esc(t('先选择你的角色,审核和自动合并才会生效。', 'Choose your role before review or auto-merge can take effect.')) : !canMaintain ? esc(t('developer 不能启用 PR 审核或自动合并;切换为 maintainer 后才可用。', 'A developer cannot enable PR review or auto-merge; switch to maintainer first.')) : !canReview ? esc(t('你在这个上游没有 admin/maintain 权限,审核角色不可用。', 'You do not have admin/maintain permission on this upstream, so the reviewer role is unavailable.')) : esc(t('自动合并默认关闭;开启后由 Fledge 在 checks 通过且没有 changes requested 时执行 squash merge。', 'Auto-merge is off by default; when on, Fledge squash-merges after checks pass and no changes are requested.'))}

@@ -520,7 +577,7 @@

${esc(t('连接', 'Connection'))}

${esc(t('任务', 'Jobs'))}

${jobRows}
${esc(t('任务', 'Job'))}${esc(t('状态', 'Status'))}PR / Issue${esc(t('摘要', 'Summary'))}
`; - return `${repoHeader(repo)}${roleCard(repo)}${qHtml}${chiefCard}${instanceCard}
${settingsCard}${connectionCard}
${jobsCard}`; + return `${repoHeader(repo)}${roleCard(repo)}${qHtml}${chiefCard}${instanceCard}${workCard}
${settingsCard}${connectionCard}
${jobsCard}`; } function newRepoScreen() { @@ -546,11 +603,20 @@

${esc(t('选择要接入的仓库', 'Choose repositories'))}

const job = repo.jobs.find((entry) => entry.id === state.activeJob) || repo.jobs[0]; if (!job) return `

${esc(t('任务不存在。', 'Job not found.'))}

`; const retry = job.status === 'failed' ? `` : ''; - const execute = job.kind === 'plan' && job.status === 'succeeded' ? `` : ''; + const execute = job.kind === 'plan' && job.status === 'succeeded' && job.planApproved ? `` : ''; + const control = job.status === 'queued' || job.status === 'running' + ? `` + : job.status === 'paused' + ? `` + : ''; + const planCard = job.kind === 'plan' && job.status === 'succeeded' + ? `

${esc(t('计划审批', 'Plan approval'))}

${job.planApproved ? esc(t('计划已批准,可以执行。', 'Plan approved; ready to execute.')) : job.planRejected ? esc(t('计划已被拒绝。', 'Plan rejected.')) : esc(t('等待 maintainer 批准计划。', 'Waiting for maintainer approval.'))}

${job.planApproved ? esc(t('已批准', 'Approved')) : job.planRejected ? esc(t('已拒绝', 'Rejected')) : esc(t('待批准', 'Pending'))}
${repo.role === 'maintainer' && !job.planApproved && !job.planRejected ? `
` : ''}
` + : ''; return `← ${esc(t('返回仓库', 'Back to repository'))}
-
${esc(t('任务', 'JOB'))}

${esc(job.id)}

${esc(repo.owner)}/${esc(repo.name)} · ${statusBadge(job.status)} · ${esc(kindLabel(job.kind))} · ${esc(triggerLabel(job.trigger))} · ${esc(t('创建', 'Created'))} ${esc(job.created)}

${execute}${retry}
+
${esc(t('任务', 'JOB'))}

${esc(job.id)}

${esc(repo.owner)}/${esc(repo.name)} · ${statusBadge(job.status)} · ${esc(kindLabel(job.kind))} · ${esc(triggerLabel(job.trigger))} · ${esc(t('创建', 'Created'))} ${esc(job.created)}

${control}${execute}${retry}
+ ${planCard}

${esc(t('结果', 'Result'))}

${esc(job.summary)}

${esc(t('成本', 'Cost'))} ${esc(job.cost)} · ${esc(t('归因', 'Attribution'))} ${esc(job.attribution)}

PR ${esc(job.pr)} · Issue ${esc(job.issue)}

${esc(t('证据', 'Evidence'))}

@@ -691,6 +757,37 @@

${esc(t('选择要接入的仓库', 'Choose repositories'))}

toast(t(`已接入 ${selected.length} 个仓库;请为每个仓库选择角色。`, `Connected ${selected.length} repositories; choose a role for each.`), 'good'); return; } + if (action === 'work-run') { + const repo = activeRepo(); + const item = (repo.workItems || []).find((entry) => entry.id === el.dataset.work); + if (item) { item.state = 'in_progress'; render(); toast(t('工作项已开始(mock)。', 'Work item started (mock).'), 'good'); } + return; + } + if (action === 'work-state') { + const repo = activeRepo(); + const item = (repo.workItems || []).find((entry) => entry.id === el.dataset.work); + const select = document.querySelector(`[data-work-state="${el.dataset.work}"]`); + if (item && select) { item.state = select.value; render(); toast(t('工作项状态已更新(mock)。', 'Work item state updated (mock).'), 'good'); } + return; + } + if (action === 'approve-plan') { + const repo = repoById(el.dataset.repo); + const job = repo.jobs.find((entry) => entry.id === el.dataset.job); + if (job) { job.planApproved = true; job.planRejected = false; render(); toast(t('计划已批准(mock)。', 'Plan approved (mock).'), 'good'); } + return; + } + if (action === 'reject-plan') { + const repo = repoById(el.dataset.repo); + const job = repo.jobs.find((entry) => entry.id === el.dataset.job); + if (job) { job.planApproved = false; job.planRejected = true; render(); toast(t('计划已拒绝(mock)。', 'Plan rejected (mock).'), 'good'); } + return; + } + if (action === 'job-control') { + const repo = repoById(el.dataset.repo); + const job = repo.jobs.find((entry) => entry.id === el.dataset.job); + if (job) { job.status = el.dataset.status; render(); toast(t('任务状态已更新(mock)。', 'Job state updated (mock).'), 'good'); } + return; + } if (action === 'execute-plan') { const repo = repoById(el.dataset.repo); const job = { id: rid('job'), kind: 'iterate', status: 'queued', trigger: 'manual', summary: t('已按计划开始执行。', 'Plan execution queued.'), pr: '—', issue: '—', cost: '$0.00', created: now(), evidence: { tests: t('未运行', 'not run'), checks: t('等待', 'waiting'), files: 0, risk: 'low', rollback: '—' }, attribution: '—' }; @@ -770,6 +867,17 @@

${esc(t('选择要接入的仓库', 'Choose repositories'))}

toast(t('仓库已添加;请选择你的角色。', 'Repository added; choose your role.'), 'good'); return; } + if (form.dataset.form === 'work-create') { + const repo = repoById(form.dataset.repo); + const data = new FormData(form); + const title = String(data.get('title') || '').trim(); + if (title === '') return; + repo.workItems = repo.workItems || []; + repo.workItems.unshift({ id: rid('work'), title, state: 'proposed', priority: Number(data.get('priority') || 0), pr: '—', issue: '—', job: '—' }); + render(); + toast(t('工作项已创建(mock)。', 'Work item created (mock).'), 'good'); + return; + } if (form.dataset.form === 'settings') { const repo = repoById(form.dataset.repo); const data = new FormData(form); @@ -778,6 +886,11 @@

${esc(t('选择要接入的仓库', 'Choose repositories'))}

repo.enabled = data.get('enabled') === 'on'; repo.autoIterate = data.get('autoIterate') === 'on'; repo.interval = Number(data.get('interval') || 1440); + repo.planRequired = data.get('planRequired') === 'on'; + repo.allowPaths = String(data.get('allowPaths') || ''); + repo.denyPaths = String(data.get('denyPaths') || ''); + repo.mergeMethod = String(data.get('mergeMethod') || 'squash'); + repo.requireProtection = data.get('requireProtection') === 'on'; repo.autoMerge = data.get('autoMerge') === 'on' && repo.role === 'maintainer'; repo.review = data.get('review') === 'on' && repo.role === 'maintainer' && (repo.permission === 'admin' || repo.permission === 'maintain'); toast(t('设置已保存(mock)。', 'Settings saved (mock).'), 'good');