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
${esc(t('先出计划,批准后执行', 'Plan first; execute only after approval'))}
${esc(t('允许修改路径', 'Allowed paths'))} ${esc(t('禁止修改路径', 'Forbidden paths'))}
${esc(t('合并方式', 'Merge method'))} ${['squash', 'merge', 'rebase'].map((method) => `${method} `).join('')} ${esc(t('自动合并前要求 branch protection', 'Require branch protection before auto-merge'))}
+ ${['Chief', 'Lead', 'Product', 'Test', 'Security', 'Reviewer', 'Dev-1', 'Dev-2'].map((role) => `${esc(role)} ${esc(t('跟随默认', 'Follow default'))} DeepSeek · deepseek-chat `).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)}` : ''}
${esc(t('允许 maintainer 自动合并 PR', 'Allow maintainer auto-merge'))}
${esc(t('启用 PR 审核', 'Enable PR reviewer'))}
@@ -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('重新检测上游 / fork', 'Refresh upstream / fork'))}
- ${esc(t('删除仓库', 'Delete repository'))}
+ ${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'))} ${repo.enabled === false ? `${esc(t('仓库已停用:不会自动或手动运行任务。', 'Repository disabled: no jobs run automatically or manually.'))} ` : ''}${repo.instance !== 'ready' ? `${esc(t('FlyBot 未就绪:任务不会运行。', 'FlyBot is not ready: jobs will not run.'))} ` : ''} ${esc(t('启用仓库', 'Enable repository'))} ${esc(t('启动 FlyBot', 'Start FlyBot'))}
`
+ : '';
+ 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.'))}
`;
+ const dangerCard = `${esc(t('危险操作', 'Danger zone'))} ${esc(t('这里的操作只影响 Fledge 连接,不会删除 GitHub 仓库。', 'These actions affect the Fledge connection only; GitHub is not deleted.'))}
${esc(t('停止调度', 'Stop scheduling'))} ${esc(t('停止 FlyBot', 'Stop FlyBot'))}
${esc(t('输入仓库名确认', 'Type the repository name to confirm'))} ${esc(repo.name)}
${esc(t('删除仓库连接', 'Delete repository connection'))} `;
const jobsCard = ``;
- 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).'))}
API tokens flg_7f2a…flybot-cli · 最近使用 2026-11-03 10:12
${esc(t('撤销', 'Revoke'))} ${esc(t('创建 token', 'Create token'))} `;
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) => `${escapeHtml(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) => `
${escapeHtml(workItemStateLabel(t, 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
${escapeHtml(t('删除仓库连接', 'Delete repository connection'))}
`
- const body = `${repoHeader}${roleCard}${lifecycleBanner}${appAttribution ? `${escapeHtml(t('当前 GitHub App 操作的 issue / PR 会以 Fledge-Platform 身份创建。重新用 GitHub 登录并授予 repo 权限后,将以你的身份创建。', 'GitHub App issue/PR actions are currently attributed to Fledge-Platform. Re-authorize your GitHub account with repo access to create them as yourself.'))}
${escapeHtml(t('重新授权 GitHub', 'Re-authorize GitHub'))} ` : ''}${questionsBlock(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${instanceNotice}${instanceCard}${deliveryCard}${workCard}${dangerCard}
+ const body = `${repoHeader}${roleCard}${lifecycleBanner}${appAttribution ? `${escapeHtml(t('当前 GitHub App 操作的 issue / PR 会以 Fledge-Platform 身份创建。重新用 GitHub 登录并授予 repo 权限后,将以你的身份创建。', 'GitHub App issue/PR actions are currently attributed to Fledge-Platform. Re-authorize your GitHub account with repo access to create them as yourself.'))}
${escapeHtml(t('重新授权 GitHub', 'Re-authorize GitHub'))} ` : ''}${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
${escapeHtml(t('默认模型', 'Default model'))} ${escapeHtml(t('平台默认', 'Platform default'))} ${models.map((model) => `${escapeHtml(model.provider_label)} · ${escapeHtml(model.label)}${repoModelIds.has(model.id) ? '' : ' *'} `).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]) => `${escapeHtml(label)} ${escapeHtml(t('跟随默认', 'Follow default'))} ${models.filter((model) => repoModelIds.has(model.id)).map((model) => `${escapeHtml(model.provider_label)} · ${escapeHtml(model.label)} `).join('')} `).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]) => `${escapeHtml(label)} ${escapeHtml(t('跟随默认', 'Follow default'))} ${models.filter((model) => repoModelIds.has(model.id)).map((model) => `${escapeHtml(model.provider_label)} · ${escapeHtml(model.label)} `).join('')} `).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)}
@@ -1400,7 +1418,7 @@ export function workspacePage(input: {
${quota.exhausted ? `
${escapeHtml(t('平台额度已用完。请到 Settings → Model 配置你自己的模型 API,任务才能继续。', 'Platform quota is used up. Add your own model API in Settings → Model to keep jobs running.'))}
` : ''}
- ${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)
+})