From fd79c2ac7057d585c6e38b1b7e5a30937aadd799 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Thu, 5 Nov 2026 16:44:39 +0800 Subject: [PATCH] feat: add issue commands, live inbox and the delivery chain Add /fledge plan/fix/status/approve issue commands, an inbox SSE stream with server-rendered updates, Fledge-driven auto-review and review outcomes, work-item stage labels, UI polish and the updated mock. --- changelog.d/inbox-sse.added.md | 1 + changelog.d/issue-commands.added.md | 1 + changelog.d/stage-chain.changed.md | 1 + changelog.d/ui-polish.changed.md | 1 + docs/architecture.md | 24 ++++++ mock/README.md | 14 ++++ mock/fledge-ui.html | 14 +++- public/app.css | 27 +++++++ public/questions.js | 115 ++++++++++++++++------------ src/app.ts | 106 +++++++++++++++++++++++++ src/jobs/service.ts | 19 +++++ src/lib/issueCommands.ts | 40 ++++++++++ src/views/pages.ts | 38 ++++++--- test/issueCommands.test.ts | 19 +++++ 14 files changed, 360 insertions(+), 60 deletions(-) create mode 100644 changelog.d/inbox-sse.added.md create mode 100644 changelog.d/issue-commands.added.md create mode 100644 changelog.d/stage-chain.changed.md create mode 100644 changelog.d/ui-polish.changed.md create mode 100644 src/lib/issueCommands.ts create mode 100644 test/issueCommands.test.ts diff --git a/changelog.d/inbox-sse.added.md b/changelog.d/inbox-sse.added.md new file mode 100644 index 0000000..f76563c --- /dev/null +++ b/changelog.d/inbox-sse.added.md @@ -0,0 +1 @@ +The question inbox now updates over Server-Sent Events at `/api/inbox/events`. Fledge sends the server-rendered inbox when the question set changes, and the browser replaces the section and rebinds the answer forms without a reload. diff --git a/changelog.d/issue-commands.added.md b/changelog.d/issue-commands.added.md new file mode 100644 index 0000000..0146688 --- /dev/null +++ b/changelog.d/issue-commands.added.md @@ -0,0 +1 @@ +Collaborators can drive a work item from the issue thread with `/fledge status`, `/fledge plan`, `/fledge approve` and `/fledge fix`. Fledge checks the author association, dedupes commands by comment id, creates or reuses the work item, and replies with the current state; plan approval remains maintainer-only. diff --git a/changelog.d/stage-chain.changed.md b/changelog.d/stage-chain.changed.md new file mode 100644 index 0000000..cf2cc96 --- /dev/null +++ b/changelog.d/stage-chain.changed.md @@ -0,0 +1 @@ +The delivery chain is now Fledge-driven: a successful iterate with a PR queues review when the reviewer is enabled, plan approval moves the work item to assigned, and a review outcome moves it to approved or changes_requested. The work-item table shows the current stage. diff --git a/changelog.d/ui-polish.changed.md b/changelog.d/ui-polish.changed.md new file mode 100644 index 0000000..7bc07f1 --- /dev/null +++ b/changelog.d/ui-polish.changed.md @@ -0,0 +1 @@ +Polished the repository lifecycle, provider cards, per-bot model grid, danger zone and delivery list, and updated the standalone user-view mock with the new provider, delivery and lifecycle screens. diff --git a/docs/architecture.md b/docs/architecture.md index e2bcdd6..9d8e85f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -280,6 +280,30 @@ job is retried, so a new job can continue from the last known point instead of starting from scratch. The add/settings goal forms also offer four task templates (bug, tests, docs, dependency) that fill the goal text. +### Issue commands and live inbox + +A collaborator can drive a work item from the issue thread with `/fledge status`, +`/fledge plan`, `/fledge approve` or `/fledge fix`. The webhook checks the author +association, dedupes by comment id, creates or reuses the work item, and replies +on the issue with the work-item/job/PR state. `/fledge approve` is maintainer-only +and approves the latest completed plan. + +The inbox is also available as `GET /api/inbox/events`, a per-user SSE stream. +The server reuses the cached inbox snapshot, sends the server-rendered inbox HTML +when the question signature changes, and the browser replaces the section and +rebinds the answer forms. The page keeps a hidden empty inbox so the first new +question can appear without a reload. + +### Fledge-driven delivery chain + +After an iterate job succeeds with a PR, Fledge queues a review job when the +repository has an enabled reviewer and no other job is active. A successful review +moves the linked work item to `approved` (score >= 80 and no follow-ups) or +`changes_requested`; follow-ups are queued on the same branch. Plan approval +moves the work item to `assigned`, and the execute action enqueues the +implementation with the work item attached. The repository work-item table shows +the stage (Scope / Implement / Review / Merge) alongside the state. + ### Repository lifecycle The repository page has an explicit lifecycle: an enabled/disabled flag, the diff --git a/mock/README.md b/mock/README.md index 8dca0e1..f19a1c9 100644 --- a/mock/README.md +++ b/mock/README.md @@ -27,3 +27,17 @@ What to review: - Global settings and the mock question inbox. - The top-right `中 / EN` switch changes the main labels; mock content stays as written. + +## Latest screens + +- Repository lifecycle banner and danger zone (stop scheduling, stop FlyBot, typed-name delete). +- Pull-request delivery list with review score, cost and a merge action. +- Model providers with multiple models per provider and platform-side connection testing. +- Per-bot model selectors (Chief, Lead, Product, Test, Security, Reviewer, Dev-1, Dev-2). + +## Latest screens + +- Repository lifecycle banner and danger zone (stop scheduling, stop FlyBot, typed-name delete). +- Pull-request delivery list with review score, cost and a merge action. +- Model providers with multiple models per provider and platform-side connection testing. +- Per-bot model selectors (Chief, Lead, Product, Test, Security, Reviewer, Dev-1, Dev-2). diff --git a/mock/fledge-ui.html b/mock/fledge-ui.html index 5a31405..121472a 100644 --- a/mock/fledge-ui.html +++ b/mock/fledge-ui.html @@ -176,6 +176,7 @@ .nav a { padding: 8px 0; } .account { display: none; } } +.provider-card{border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:12px;background:rgba(255,255,255,.02)}.provider-card ul{margin:8px 0;padding-left:18px}.bot-models{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}#danger{border-color:rgba(240,116,116,.35);background:linear-gradient(180deg,rgba(240,116,116,.06),transparent 70%)} @@ -558,6 +559,8 @@

${esc(t('选择你在这个仓库的角色', 'Choose your role for this repo
+
${['Chief', 'Lead', 'Product', 'Test', 'Security', 'Reviewer', 'Dev-1', 'Dev-2'].map((role) => ``).join('')}
+

${esc(t('每个 bot 可以单独选择模型;不选则跟随默认。应用模型后 Fledge 会通过 DSH 分配。', 'Each bot can choose a model; empty follows the default. Applying models assigns them through DSH.'))}

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

@@ -570,14 +573,19 @@

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

${esc(t('GitHub token 已加密保存;页面不会显示明文。如果仓库或 token 变了,删除后重新添加。', 'The GitHub token is encrypted and never rendered in plain text. If the repository or token changes, delete and add it again.'))}

${esc(t('审核最近运行', 'Last review'))} ${esc(repo.review ? t('已启用', 'Enabled') : t('未启用', 'Disabled'))}

- +

${esc(t('删除仓库连接请见危险操作区。', 'See the danger zone to delete the repository connection.'))}

${esc(t('平台只通过 GitHub API 读取元数据、创建 fork 和分支/PR;不会 clone 到公共磁盘。', 'The platform only uses the GitHub API to read metadata, create forks and open branches/PRs; it never clones to public disk.'))}

`; + const lifecycleCard = repo.enabled === false || repo.instance !== 'ready' + ? `
${esc(t('当前状态', 'Current state'))}
` + : ''; + const deliveryCard = `

${esc(t('待处理 PR', 'Pull requests to decide'))}

${esc(t('合并前会重新检查 PR 状态、checks、commit status、reviews 和 branch protection。', 'Merging re-checks the PR state, checks, commit status, reviews and branch protection.'))}

PR${esc(t('状态', 'Status'))}${esc(t('审核分', 'Review'))}${esc(t('成本', 'Cost'))}
#21${statusBadge('ready')}93¥5.97
#19${statusBadge('ready')}91¥8.79
`; + const dangerCard = `

${esc(t('危险操作', 'Danger zone'))}

${esc(t('这里的操作只影响 Fledge 连接,不会删除 GitHub 仓库。', 'These actions affect the Fledge connection only; GitHub is not deleted.'))}

`; const jobsCard = `

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

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

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

function settingsScreen() { return `
${esc(t('设置', 'SETTINGS'))}

${esc(t('设置', 'Settings'))}

${esc(t('账号、模型、通知和 API token(mock)。', 'Account, model, notifications and API tokens (mock).'))}

-

${esc(t('自己的模型', 'Your model'))}

+

${esc(t('模型提供方', 'Model providers'))}

${esc(t('添加提供方(Base URL + API Key),再在下面添加多个模型。添加模型时 Fledge 会测试;失败不会保存。', 'Add a provider (Base URL + API key), then add models. Adding a model runs a Fledge test; failure is not saved.'))}

DeepSeekhttps://api.deepseek.com/v1
  • deepseek-chat
  • deepseek-reasoner

${esc(t('通知', 'Notifications'))}

API tokens

flg_7f2a…flybot-cli · 最近使用 2026-11-03 10:12
`; diff --git a/public/app.css b/public/app.css index 681a4cc..11e69a5 100644 --- a/public/app.css +++ b/public/app.css @@ -416,3 +416,30 @@ a.list-row:hover strong { color: var(--accent); } .info-tip { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; margin-left: 4px; border: 1px solid var(--line); border-radius: 50%; color: var(--muted-2); font-size: 10px; line-height: 1; cursor: help; position: relative; vertical-align: middle; } .info-tip::after { content: attr(data-tip); position: absolute; left: 0; top: 130%; z-index: 60; width: 270px; max-width: min(270px, calc(100vw - 48px)); padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: #0b121b; color: #dbe6f3; font-size: 11.5px; font-weight: 400; line-height: 1.45; white-space: normal; overflow-wrap: anywhere; opacity: 0; pointer-events: none; transform: translateY(-3px); transition: opacity .12s ease; box-shadow: 0 8px 24px rgba(0, 0, 0, .35); } .info-tip:hover::after, .info-tip:focus::after { opacity: 1; } + +.provider-card { + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px; + margin-bottom: 12px; + background: rgba(255, 255, 255, 0.02); +} +.provider-card ul { + margin: 8px 0; + padding-left: 18px; +} +.provider-card li { + margin: 4px 0; +} +.bot-models { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; +} +#danger { + border-color: rgba(240, 116, 116, 0.35); + background: linear-gradient(180deg, rgba(240, 116, 116, 0.06), transparent 70%); +} +.inbox[hidden] { + display: none; +} diff --git a/public/questions.js b/public/questions.js index a3c6162..8c0438f 100644 --- a/public/questions.js +++ b/public/questions.js @@ -1,6 +1,4 @@ (() => { - const forms = document.querySelectorAll('form[data-answer-form]') - if (forms.length === 0) return const zh = (document.documentElement.dataset.locale || 'zh') !== 'en' const t = (a, b) => (zh ? a : b) const empty = () => { @@ -13,52 +11,75 @@ const count = document.querySelector('[data-inbox-count]') if (count) count.textContent = '0' } - for (const form of forms) { - form.addEventListener('submit', async (event) => { - event.preventDefault() - const error = form.querySelector('[data-question-error]') - const button = form.querySelector('button[type="submit"]') - const selected = [...form.querySelectorAll('input[name="selected"]:checked')].map((input) => input.value) - const customInput = form.querySelector('input[name="custom"]') - const custom = customInput ? customInput.value.trim() : '' - const show = (message) => { - if (!error) return - error.hidden = false - error.textContent = message - } - if (selected.length === 0 && custom === '') { - show(t('请选择一个选项,或填写自己的答案。', 'Choose an option or type your own answer.')) - return - } - if (error) { - error.hidden = true - error.textContent = '' - } - const original = button ? button.textContent : '' - if (button) { - button.disabled = true - button.textContent = t('发送中…', 'Sending…') - } - try { - const response = await fetch(form.action, { - method: 'POST', - headers: { accept: 'application/json' }, - body: new FormData(form), - }) - const data = await response.json().catch(() => ({})) - if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`) - const card = form.closest('[data-question]') - card?.remove() - const count = document.querySelector('[data-inbox-count]') - if (count) count.textContent = String(Math.max(0, Number(count.textContent || '0') - 1)) - if (!document.querySelector('[data-question]')) empty() - } catch (cause) { - show(cause instanceof Error ? cause.message : String(cause)) + const bind = () => { + const forms = document.querySelectorAll('form[data-answer-form]') + for (const form of forms) { + if (form.dataset.answerBound === '1') continue + form.dataset.answerBound = '1' + form.addEventListener('submit', async (event) => { + event.preventDefault() + const error = form.querySelector('[data-question-error]') + const button = form.querySelector('button[type="submit"]') + const selected = [...form.querySelectorAll('input[name="selected"]:checked')].map((input) => input.value) + const customInput = form.querySelector('input[name="custom"]') + const custom = customInput ? customInput.value.trim() : '' + const show = (message) => { + if (!error) return + error.hidden = false + error.textContent = message + } + if (selected.length === 0 && custom === '') { + show(t('请选择一个选项,或填写自己的答案。', 'Choose an option or type your own answer.')) + return + } + if (error) { + error.hidden = true + error.textContent = '' + } + const original = button ? button.textContent : '' if (button) { - button.disabled = false - button.textContent = original + button.disabled = true + button.textContent = t('发送中…', 'Sending…') + } + try { + const response = await fetch(form.action, { + method: 'POST', + headers: { accept: 'application/json' }, + body: new FormData(form), + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`) + const card = form.closest('[data-question]') + card?.remove() + const count = document.querySelector('[data-inbox-count]') + if (count) count.textContent = String(Math.max(0, Number(count.textContent || '0') - 1)) + if (!document.querySelector('[data-question]')) empty() + } catch (cause) { + show(cause instanceof Error ? cause.message : String(cause)) + if (button) { + button.disabled = false + button.textContent = original + } } - } - }) + }) + } } + bind() + const inbox = document.querySelector('#inbox') + if (inbox === null || typeof EventSource === 'undefined') return + const source = new EventSource('/api/inbox/events') + source.addEventListener('inbox', (event) => { + try { + const data = JSON.parse(event.data) + if (typeof data.html !== 'string') return + const current = document.querySelector('#inbox') + if (current === null) return + const template = document.createElement('template') + template.innerHTML = data.html.trim() + const next = template.content.firstElementChild + if (next === null) return + current.replaceWith(next) + bind() + } catch {} + }) })() diff --git a/src/app.ts b/src/app.ts index 0b79342..cea817a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -30,6 +30,7 @@ import { allowedRepoRoles, parseRepoRole, roleAllowed } from './lib/repoRole.js' import { isWorkItemState, MAINTAINER_ONLY_STATES, WORK_ITEM_TRANSITIONS, type WorkItemState } from './lib/workItems.js' import { parseStageMode } from './lib/stages.js' import { issuePriority, issueState, shouldTriage } from './lib/issueIntake.js' +import { parseIssueCommand } from './lib/issueCommands.js' import { VERSION } from './version.js' import { Approvals } from './users/approval.js' import { @@ -50,6 +51,7 @@ import { workspacePage, agentWorkspacePage, settingsPage, + inboxSection, errorPage, type InboxQuestion, } from './views/pages.js' @@ -1221,6 +1223,41 @@ export async function buildApp(options: BuildOptions): Promise { await tick() }) + app.get('/api/inbox/events', async (request, reply) => { + const viewer = requireApproved(request) + reply.hijack() + reply.raw.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'x-accel-buffering': 'no', + }) + let closed = false + request.raw.on('close', () => { closed = true }) + let last = '' + const send = (event: string, data: unknown): void => { + if (!closed) reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + } + const tick = async (): Promise => { + try { + const repos = store.listRepos(viewer.user.id) + const questions = await inboxForUser(viewer.user.id, repos) + const signature = questions.map((question) => `${question.repoId}:${question.sessionId}:${question.id}`).sort().join('|') + if (signature === last) { + if (!closed) reply.raw.write(': ping\n\n') + return + } + last = signature + send('inbox', { html: inboxSection(request.locale ?? 'zh', viewOf(viewer), questions, '/console'), count: questions.length }) + } catch (error) { + send('error', { message: error instanceof Error ? error.message : String(error) }) + } + } + const timer = setInterval(() => void tick(), 5_000) + request.raw.on('close', () => clearInterval(timer)) + await tick() + }) + app.post('/api/repos/:id/team/answer', async (request, reply) => { const { viewer, repo } = ownedRepo(request, String((request.params as Record).id), true) requireAdvanced(viewer) @@ -1347,6 +1384,7 @@ export async function buildApp(options: BuildOptions): Promise { const allow = (body.allowPaths ?? repo.allow_paths).trim().slice(0, 2000) const deny = (body.denyPaths ?? repo.deny_paths).trim().slice(0, 2000) store.updateJob(job.id, { plan_approved_at: nowIso(), plan_approved_by: viewer.user.id, plan_rejected_at: null, plan_allow_paths: allow, plan_deny_paths: deny }) + if (job.work_item_id !== null) store.updateWorkItem(job.work_item_id, { state: 'assigned' }) store.audit(viewer.user.id, 'job.plan_approved', `${job.id} allow=${allow.slice(0, 200)} deny=${deny.slice(0, 200)}`) return reply.redirect(`/repos/${repo.id}/jobs/${job.id}?notice=${encodeURIComponent('计划已批准,可以执行。')}`) }) @@ -2258,6 +2296,73 @@ export async function buildApp(options: BuildOptions): Promise { } } + const handleIssueCommand = async (event: string, body: Record): Promise => { + if (event !== 'issue_comment' || body.action !== 'created') return + const installation = typeof body.installation === 'object' && body.installation !== null ? (body.installation as Record) : {} + const installationId = Number(installation.id) + if (!Number.isFinite(installationId)) return + const repository = typeof body.repository === 'object' && body.repository !== null ? (body.repository as Record) : {} + const fullName = typeof repository.full_name === 'string' ? repository.full_name : '' + if (fullName === '') return + const repo = store.findRepoByInstallationAndFullName(installationId, fullName) + if (repo === undefined) return + const parsed = parseIssueCommand(body) + if (parsed === null) return + const { command, issueUrl, number, title, detail } = parsed + const key = `issue_command:${parsed.commentId}` + if (store.getSetting(key) !== undefined) return + store.setSetting(key, nowIso()) + let item = store.findWorkItemByIssueUrl(repo.id, issueUrl) + if (item === undefined) { + item = store.createWorkItem({ id: randomId('work'), repoId: repo.id, userId: repo.user_id, title, detail, issueUrl, state: 'proposed' }) + } + const token = await repoGithubToken(repo, config, githubApp, store) + const client = new GithubClient(token, config.github.apiBase) + const owner = repo.upstream_owner || repo.owner + const name = repo.upstream_name || repo.name + const reply = async (message: string): Promise => { + await client.comment(owner, name, number, `${message}\n\nFledge · issue command`) + } + if (command === 'status') { + const latest = store.listJobsForRepo(repo.id, 20).find((job) => job.work_item_id === item.id) + await reply(`Work item ${item.id}: ${item.state}. ${latest === undefined ? 'No job yet.' : `Latest job ${latest.id}: ${latest.status}${latest.pr_url === null ? '' : ` · ${latest.pr_url}`}`}`) + return + } + if (store.activeJobForRepo(repo.id) !== undefined) { + await reply('A job is already running for this repository; wait for it to finish.') + return + } + if (command === 'approve') { + if (repo.repo_role !== 'maintainer') { + await reply('Only a maintainer can approve a plan.') + return + } + const plan = store.listJobsForRepo(repo.id, 20).find((job) => job.kind === 'plan' && job.status === 'succeeded' && job.work_item_id === item.id && job.plan_approved_at === null) + if (plan === undefined) { + await reply('No plan is waiting for approval.') + return + } + store.updateJob(plan.id, { plan_approved_at: nowIso(), plan_approved_by: repo.user_id, plan_rejected_at: null }) + store.updateWorkItem(item.id, { state: 'assigned' }) + store.audit(repo.user_id, 'plan.approved', `${plan.id} via issue command`) + await reply(`Plan ${plan.id} approved. Run /fledge fix to start implementation.`) + return + } + if (command === 'plan') { + const goal = `Issue ${issueUrl}\nTitle: ${title}\n${detail}\n\nProduce one concrete plan only; do not write code, create a branch or open a PR.` + const result = await jobs.enqueue(repo, 'manual', goal, 'plan', { workItemId: item.id }) + await reply(result.ok ? `Plan job ${result.job?.id ?? ''} queued.` : `Could not queue a plan: ${result.error ?? 'unknown error'}`) + return + } + if (repo.plan_required === 1 && item.state !== 'approved' && item.state !== 'assigned' && item.state !== 'pr_ready') { + await reply('This repository requires an approved plan first. Run /fledge plan, then /fledge approve.') + return + } + const goal = `Issue ${issueUrl}\nTitle: ${title}\n${detail}\n\nImplement one focused fix for this issue.` + const result = await jobs.enqueue(repo, 'manual', goal, 'iterate', { workItemId: item.id }) + await reply(result.ok ? `Implementation job ${result.job?.id ?? ''} queued.` : `Could not queue a fix: ${result.error ?? 'unknown error'}`) + } + const handleWorkItemEvent = async (event: string, body: Record): Promise => { if (event !== 'issues' && event !== 'pull_request' && event !== 'pull_request_review') return const installation = typeof body.installation === 'object' && body.installation !== null ? (body.installation as Record) : {} @@ -2357,6 +2462,7 @@ export async function buildApp(options: BuildOptions): Promise { if (action === 'deleted' && Number.isFinite(installationId)) store.deleteInstallation(installationId) } await handleWorkItemEvent(event, body).catch((error) => log.warn('github work item sync failed', { event, error: String(error) })) + await handleIssueCommand(event, body).catch((error) => log.warn('github issue command failed', { event, error: String(error) })) await handleGithubFollowup(event, body).catch((error) => log.warn('github follow-up failed', { event, error: String(error) })) return { ok: true } }) diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 462e51f..51f4df3 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -532,6 +532,7 @@ export class Jobs { } if (result.status === 'succeeded') { void this.afterSucceeded(job, prUrl ?? undefined, issueUrl ?? undefined, attribution).catch((error) => log.warn('post-job side effects failed', { job: jobId, error: String(error) })) + void this.autoQueueReview(jobId).catch((error) => log.warn('auto review queue failed', { job: jobId, error: String(error) })) } else { void this.captureTeamStats(jobId, attribution).catch((error) => log.warn('team stats capture failed', { job: jobId, error: String(error) })) } @@ -775,6 +776,16 @@ export class Jobs { } } + private async autoQueueReview(jobId: string): Promise { + const job = this.store.getJob(jobId) + if (job === undefined || job.kind !== 'iterate' || job.pr_url === null) return + const repo = this.store.getRepo(job.repo_id) + if (repo === undefined || repo.reviewer_enabled !== 1 || repo.can_review !== 1) return + if (this.store.activeJobForRepo(repo.id) !== undefined) return + const result = await this.enqueue(repo, 'schedule', undefined, 'review', job.work_item_id === null ? {} : { workItemId: job.work_item_id }) + if (result.ok) this.store.audit(job.user_id, 'review.auto_queued', `${job.id} -> ${result.job?.id ?? ''}`) + } + private async afterSucceeded(job: JobRow, prUrl: string | undefined, issueUrl: string | undefined, claimed: Attribution | null): Promise { const observed = await this.captureTeamStats(job.id, claimed).catch(() => null) await this.postAutomationComment(job, { prUrl, issueUrl }, observed ?? claimed) @@ -1151,6 +1162,14 @@ export class Jobs { await this.queueReviewFollowUps(job, followUps) await this.releasePendingFollowups(job.repo_id) } + if (status === 'succeeded' && job.kind === 'review' && job.work_item_id !== null) { + const item = this.store.getWorkItem(job.work_item_id) + if (item !== undefined) { + const outcome = followUps.length > 0 || (reviewScore !== undefined && reviewScore < 80) ? 'changes_requested' : 'approved' + this.store.updateWorkItem(item.id, { state: outcome }) + this.store.audit(job.user_id, 'work_item.review_outcome', `${item.id}: ${outcome} score=${reviewScore ?? '-'}`) + } + } if (status === 'succeeded' && prUrl !== undefined) await this.maybeAutoMerge(job, prUrl) return { ok: true } } diff --git a/src/lib/issueCommands.ts b/src/lib/issueCommands.ts new file mode 100644 index 0000000..3cb7a97 --- /dev/null +++ b/src/lib/issueCommands.ts @@ -0,0 +1,40 @@ +export type IssueCommandName = 'status' | 'plan' | 'fix' | 'approve' + +export interface IssueCommandInput { + command: IssueCommandName + issueUrl: string + number: number + title: string + detail: string + association: string + commentId: string +} + +const ALLOWED_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']) +const COMMANDS = new Set(['status', 'plan', 'fix', 'approve']) + +export function parseIssueCommand(body: Record): IssueCommandInput | null { + if (body.action !== 'created') return null + const comment = typeof body.comment === 'object' && body.comment !== null ? (body.comment as Record) : {} + const issue = typeof body.issue === 'object' && body.issue !== null ? (body.issue as Record) : {} + const association = typeof comment.author_association === 'string' ? comment.author_association : '' + if (!ALLOWED_ASSOCIATIONS.has(association)) return null + const text = typeof comment.body === 'string' ? comment.body.trim() : '' + const match = /^\/fledge\b\s*(\w*)/i.exec(text) + if (match === null) return null + const command = (match[1] === '' ? 'status' : match[1]!.toLowerCase()) as IssueCommandName + if (!COMMANDS.has(command)) return null + const issueUrl = typeof issue.html_url === 'string' ? issue.html_url : '' + const number = typeof issue.number === 'number' ? issue.number : 0 + const commentId = typeof comment.id === 'number' || typeof comment.id === 'string' ? String(comment.id) : '' + if (issueUrl === '' || number === 0 || commentId === '') return null + return { + command, + issueUrl, + number, + title: typeof issue.title === 'string' ? issue.title.slice(0, 200) : '', + detail: typeof issue.body === 'string' ? issue.body.slice(0, 3000) : '', + association, + commentId, + } +} diff --git a/src/views/pages.ts b/src/views/pages.ts index abfdc5e..0974f44 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -247,10 +247,11 @@ function jobList(locale: Locale, jobs: JobRow[], repoLabels: Map .join('')}` } -function questionsBlock(locale: Locale, viewer: Viewer, questions: InboxQuestion[], returnTo: string): string { - if (questions.length === 0) return '' +export function inboxSection(locale: Locale, viewer: Viewer, questions: InboxQuestion[], returnTo: string): string { const t = translator(locale) - const cards = questions + const cards = questions.length === 0 + ? `

${escapeHtml(t('没有待回答的问题。', 'No open questions.'))}

` + : questions .map((question) => { const options = question.options .map((option) => ``) @@ -276,7 +277,7 @@ function questionsBlock(locale: Locale, viewer: Viewer, questions: InboxQuestion ` }) .join('') - return `
+ return `
${escapeHtml(t('需要你处理', 'NEEDS YOUR INPUT'))}
@@ -437,7 +438,7 @@ export function dashboardPage(input: {
- ${questionsBlock(locale, viewer, questions, '/dashboard')} + ${inboxSection(locale, viewer, questions, '/dashboard')}
@@ -633,7 +634,7 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer const stateOptions = nextStates.map((state) => ``).join('') return ` ${escapeHtml(item.title)}${item.detail === '' ? '' : `
${escapeHtml(item.detail.slice(0, 180))}`} - ${escapeHtml(workItemStateLabel(t, item.state))} + ${escapeHtml(workItemStateLabel(t, item.state))}
${escapeHtml(workItemStageLabel(t, item.state))} ${item.priority} ${item.issue_url === null ? '' : `Issue`}${item.issue_url !== null && item.pr_url !== null ? ' · ' : ''}${item.pr_url === null ? '' : `PR`} ${item.job_id === null ? '—' : `${escapeHtml(item.job_id)}`} @@ -693,7 +694,7 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
` - const body = `${repoHeader}${roleCard}${lifecycleBanner}${appAttribution ? `` : ''}${questionsBlock(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${instanceNotice}${instanceCard}${deliveryCard}${workCard}${dangerCard} + const body = `${repoHeader}${roleCard}${lifecycleBanner}${appAttribution ? `` : ''}${inboxSection(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${instanceNotice}${instanceCard}${deliveryCard}${workCard}${dangerCard}

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

@@ -742,7 +743,7 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
- ${models.length === 0 ? '' : `
${([['chief', 'Chief'], ['lead', 'Lead'], ['product', 'Product'], ['test', 'Test'], ['security', 'Security'], ['reviewer', 'Reviewer'], ['dev1', 'Dev-1'], ['dev2', 'Dev-2']] as const).map(([role, label]) => ``).join('')}
+ ${models.length === 0 ? '' : `
${([['chief', 'Chief'], ['lead', 'Lead'], ['product', 'Product'], ['test', 'Test'], ['security', 'Security'], ['reviewer', 'Reviewer'], ['dev1', 'Dev-1'], ['dev2', 'Dev-2']] as const).map(([role, label]) => ``).join('')}

${escapeHtml(t('每个 bot 可以单独选择模型;不选则跟随默认。保存选择后,空闲时点击“应用模型并重启工作区”才会生效,Fledge 会通过 DSH 把模型分配给对应 bot。', 'Each bot can choose a model; empty follows the default. After saving, use "Apply models and restart workspace" while idle; Fledge assigns the model through DSH.'))}

`} ${roleMissing ? `

${escapeHtml(t('先选择你的角色,审核和自动合并才会生效。', 'Choose your role before review or auto-merge can take effect.'))}

` : !githubCanReview ? `

${escapeHtml(t('你在这个上游没有 admin/maintain 权限,审核角色不可用。', 'You do not have admin/maintain permission on this upstream, so the reviewer role is unavailable.'))}

` : !canMaintain ? `

${escapeHtml(t('developer 不能启用 PR 审核或自动合并;切换为 maintainer 后才可用。', 'A developer cannot enable PR review or auto-merge; switch to maintainer first.'))}

` : ''} ${advanced ? `
${escapeHtml(t('团队与调度规则', 'Team and scheduling rules'))} @@ -771,6 +772,23 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer return layout({ title: `${upstreamOwner}/${upstreamName}`, viewer, body: `${body}${advanced ? '' : ''}`, config, locale, path: `/repos/${repo.id}`, active: 'dashboard', notice, error }) } +function workItemStageLabel(t: Translate, state: string): string { + const map: Record = { + proposed: ['范围', 'Scope'], + triaged: ['范围', 'Scope'], + assigned: ['范围', 'Scope'], + in_progress: ['实现', 'Implement'], + pr_ready: ['审核', 'Review'], + in_review: ['审核', 'Review'], + changes_requested: ['修改', 'Fix'], + approved: ['合并', 'Merge'], + merged: ['完成', 'Done'], + closed: ['关闭', 'Closed'], + } + const entry = map[state] ?? ['—', '—'] + return t(entry[0], entry[1]) +} + function teamRoleLabel(t: Translate, role: string): string { const labels: Record = { chief: ['总控', 'Chief'], @@ -966,7 +984,7 @@ export function settingsPage(input: {

${escapeHtml(t('先添加提供方(Base URL + API Key),再在它下面添加模型。添加模型时 Fledge 会发起 1-token 测试;测试失败不会保存。', 'Add a provider (Base URL + API key), then add models under it. Adding a model sends a 1-token test from Fledge; a failed test is not saved.'))}

${providers.length === 0 ? `

${escapeHtml(t('还没有模型提供方。', 'No model providers yet.'))}

` - : providers.map((provider) => `
+ : providers.map((provider) => `
${escapeHtml(provider.label)}${provider.allow_private === 1 ? ' · private' : ''}
${escapeHtml(provider.base_url)}
${csrf(viewer)}
@@ -1400,7 +1418,7 @@ export function workspacePage(input: {
${quota.exhausted ? `` : ''} - ${questionsBlock(locale, viewer, questions, '/console')} + ${inboxSection(locale, viewer, questions, '/console')}
${escapeHtml(t('仓库', 'Repositories'))}${repos.length}${escapeHtml(t('已接入', 'Connected'))} ${escapeHtml(t('进行中', 'In progress'))}${active}${escapeHtml(t('排队或运行', 'Queued or running'))} diff --git a/test/issueCommands.test.ts b/test/issueCommands.test.ts new file mode 100644 index 0000000..860ebbb --- /dev/null +++ b/test/issueCommands.test.ts @@ -0,0 +1,19 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { parseIssueCommand } from '../src/lib/issueCommands.js' + +const issue = { number: 7, html_url: 'https://github.com/o/r/issues/7', title: 'Fix it', body: 'details' } +const base = { action: 'created', issue } + +test('issue commands parse only from allowed collaborators and known commands', () => { + assert.deepEqual(parseIssueCommand({ ...base, comment: { id: 1, body: '/fledge status', author_association: 'MEMBER' } }), { + command: 'status', issueUrl: issue.html_url, number: 7, title: 'Fix it', detail: 'details', association: 'MEMBER', commentId: '1', + }) + assert.equal(parseIssueCommand({ ...base, comment: { id: 2, body: '/fledge plan', author_association: 'MEMBER' } })?.command, 'plan') + assert.equal(parseIssueCommand({ ...base, comment: { id: 3, body: '/fledge fix', author_association: 'COLLABORATOR' } })?.command, 'fix') + assert.equal(parseIssueCommand({ ...base, comment: { id: 4, body: '/fledge approve', author_association: 'OWNER' } })?.command, 'approve') + assert.equal(parseIssueCommand({ ...base, comment: { id: 5, body: '/fledge', author_association: 'OWNER' } })?.command, 'status') + assert.equal(parseIssueCommand({ ...base, comment: { id: 6, body: '/fledge status', author_association: 'CONTRIBUTOR' } }), null) + assert.equal(parseIssueCommand({ ...base, comment: { id: 7, body: '/fledge nope', author_association: 'OWNER' } }), null) + assert.equal(parseIssueCommand({ ...base, action: 'edited', comment: { id: 8, body: '/fledge status', author_association: 'OWNER' } }), null) +})