From 9758028cd77e3e0f4ff9ce7a9a864566323104de Mon Sep 17 00:00:00 2001 From: HugoXK Date: Thu, 5 Nov 2026 15:22:13 +0800 Subject: [PATCH] feat: add explicit repository lifecycle and safe deletion Show disabled, stopped-FlyBot and invalid-credential banners, disable Run/Plan/Review until the repository is runnable, and add a danger zone with stop scheduling, stop FlyBot and typed-name deletion. --- changelog.d/repo-lifecycle.changed.md | 1 + docs/architecture.md | 9 +++++++ src/app.ts | 21 ++++++++++++++- src/views/pages.ts | 39 ++++++++++++++++++++++----- test/pages.test.ts | 30 +++++++++++++++++++-- 5 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 changelog.d/repo-lifecycle.changed.md diff --git a/changelog.d/repo-lifecycle.changed.md b/changelog.d/repo-lifecycle.changed.md new file mode 100644 index 0000000..013eaa5 --- /dev/null +++ b/changelog.d/repo-lifecycle.changed.md @@ -0,0 +1 @@ +Repository pages now show an explicit lifecycle banner for disabled repositories, stopped FlyBot instances and invalid credentials, and disable Run/Plan/Review until the state is fixed. A danger zone separates stop scheduling, stop FlyBot and delete; deleting requires typing the repository name and only removes the Fledge connection, instance, jobs and tokens. diff --git a/docs/architecture.md b/docs/architecture.md index 432eb4a..96c5594 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -280,6 +280,15 @@ 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. +### Repository lifecycle + +The repository page has an explicit lifecycle: an enabled/disabled flag, the +FlyBot instance state, and the credential state. A top banner explains when the +repository is disabled, FlyBot is stopped or the token is invalid, and disables +Run/Plan/Review until the state is fixed. A danger zone offers stop scheduling, +stop FlyBot and delete; deletion requires typing the repository name and removes +the Fledge connection, instance, jobs and tokens without touching GitHub. + ### Pull-request delivery Each repository page has a delivery list of iterate jobs with open pull requests, diff --git a/src/app.ts b/src/app.ts index 6543717..e371613 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1715,8 +1715,27 @@ export async function buildApp(options: BuildOptions): Promise { } }) + app.post('/repos/:id/enable', async (request, reply) => { + const { viewer, repo } = ownedRepo(request, String((request.params as Record).id), true) + store.updateRepo(repo.id, { enabled: 1 }) + store.audit(viewer.user.id, 'repo.enabled', `${repo.owner}/${repo.name}`) + return reply.redirect(`/repos/${repo.id}?notice=${encodeURIComponent('仓库已启用;自动迭代不会自动打开,需要时在设置里开启。')}`) + }) + + app.post('/repos/:id/disable', async (request, reply) => { + const { viewer, repo } = ownedRepo(request, String((request.params as Record).id), true) + store.updateRepo(repo.id, { enabled: 0, auto_iterate: 0 }) + await jobs.cancelRepoJobs(repo.id, '仓库已停用') + store.audit(viewer.user.id, 'repo.disabled', `${repo.owner}/${repo.name}`) + return reply.redirect(`/repos/${repo.id}?notice=${encodeURIComponent('仓库已停用,任务不会再运行。')}`) + }) + app.post('/repos/:id/delete', async (request, reply) => { const { viewer, repo } = ownedRepo(request, String((request.params as Record).id), true) + const confirmName = (form(request).confirmName ?? '').trim() + if (confirmName !== repo.name && confirmName !== `${repo.owner}/${repo.name}`) { + return reply.redirect(`/repos/${repo.id}?error=${encodeURIComponent('请输入仓库名确认删除。')}#danger`) + } for (const job of store.listActiveJobs().filter((entry) => entry.repo_id === repo.id)) { const token = job.platform_token_hash === null ? undefined : store.findApiToken(job.platform_token_hash) if (token !== undefined) store.revokeApiToken(token.id, token.user_id) @@ -1724,7 +1743,7 @@ export async function buildApp(options: BuildOptions): Promise { await instances.remove(repo.id) store.deleteRepo(repo.id) store.audit(viewer.user.id, 'repo.deleted', `${repo.owner}/${repo.name}`) - return reply.redirect(`/dashboard?notice=${encodeURIComponent('仓库已删除。')}`) + return reply.redirect(`/dashboard?notice=${encodeURIComponent('仓库连接已删除;GitHub 仓库不受影响。')}`) }) app.get('/settings', async (request, reply) => { diff --git a/src/views/pages.ts b/src/views/pages.ts index e7f8533..a50af22 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -512,6 +512,11 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer : humanTimeShort(nextRun.toISOString()) const instanceTeam = instance === undefined ? null : parseTeam(instance.team_json) const instanceReady = instance?.status === 'ready' && instanceTeam !== null + const flybotAvailable = config.flybot.instanceImage !== '' + const flybotReady = !flybotAvailable || instance?.status === 'ready' + const repoEnabled = repo.enabled === 1 + const credentialOk = repo.credential_state !== 'invalid' + const runnable = repoEnabled && flybotReady && credentialOk const instanceNotice = config.flybot.instanceImage === '' ? `` : instance === undefined @@ -556,9 +561,9 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
${escapeHtml(t('工作区', 'Workspace'))} ${advanced ? `${escapeHtml(t('团队工作台', 'Team console'))}` : ''} - ${roleMissing ? `${escapeHtml(t('先选择角色', 'Choose a role first'))}` : `
${csrf(viewer)}
+ ${roleMissing ? `${escapeHtml(t('先选择角色', 'Choose a role first'))}` : !runnable ? `${escapeHtml(t('仓库不可运行', 'Not runnable'))}` : `
${csrf(viewer)}
${csrf(viewer)}
`} - ${!roleMissing && canReview && reviewerEnabled ? `
${csrf(viewer)}
` : ''} + ${!roleMissing && runnable && canReview && reviewerEnabled ? `
${csrf(viewer)}
` : ''}
` @@ -663,10 +668,31 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer ${repo.auto_merge === 0 && prJobs.length >= 3 ? `` : ''}

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

${prRows}
PR${escapeHtml(t('状态', 'Status'))}${escapeHtml(t('审核分', 'Review'))}${escapeHtml(t('成本', 'Cost'))}${escapeHtml(t('创建', 'Created'))}
` - const credentialBanner = repo.credential_state === 'invalid' || repo.consecutive_failures >= 3 - ? `` - : '' - const body = `${repoHeader}${roleCard}${credentialBanner}${appAttribution ? `` : ''}${questionsBlock(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${instanceNotice}${instanceCard}${deliveryCard}${workCard} + const lifecycleItems: string[] = [] + if (!repoEnabled) lifecycleItems.push(t('仓库已停用:不会自动或手动运行任务。', 'Repository disabled: no jobs run automatically or manually.')) + if (flybotAvailable && instance?.status !== 'ready') lifecycleItems.push(t(`FlyBot ${instance === undefined ? '未创建' : statusLabel(t, instance.status)}:任务不会运行。`, `FlyBot is ${instance === undefined ? 'not created' : statusLabel(t, instance.status)}: jobs will not run.`)) + if (!credentialOk) lifecycleItems.push(t('GitHub 凭据失效:调度已暂停。', 'GitHub credentials are invalid: scheduling is paused.')) + if (repo.auto_iterate === 1 && (!repoEnabled || !flybotReady)) lifecycleItems.push(t('自动迭代已开启,但当前不会运行。', 'Auto-iteration is on but cannot run right now.')) + const lifecycleActions = [ + !repoEnabled ? `
${csrf(viewer)}
` : '', + flybotAvailable && instance?.status !== 'ready' ? `
${csrf(viewer)}
` : '', + !credentialOk ? `${escapeHtml(t('重新授权 GitHub', 'Re-authorize GitHub'))}` : '', + ].join('') + const lifecycleBanner = lifecycleItems.length === 0 ? '' : `` + const dangerCard = `
+

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

+

${escapeHtml(t('这里的操作只影响 Fledge 连接,不会删除 GitHub 仓库。停止调度会保留实例和历史;停止 FlyBot 会停止容器;删除会移除连接、实例、任务和 token。', 'These actions affect the Fledge connection only; the GitHub repository is not deleted. Stop scheduling keeps the instance and history; Stop FlyBot stops the container; Delete removes the connection, instance, jobs and tokens.'))}

+
+ ${repoEnabled ? `
${csrf(viewer)}
` : `
${csrf(viewer)}
`} + ${flybotAvailable ? (instance?.status === 'ready' ? `
${csrf(viewer)}
` : `
${csrf(viewer)}
`) : ''} +
+
+ ${csrf(viewer)} + + +
+
` + const body = `${repoHeader}${roleCard}${lifecycleBanner}${appAttribution ? `` : ''}${questionsBlock(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${instanceNotice}${instanceCard}${deliveryCard}${workCard}${dangerCard}

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

@@ -732,7 +758,6 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
${csrf(viewer)}
-
${csrf(viewer)}

${escapeHtml(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.'))}

diff --git a/test/pages.test.ts b/test/pages.test.ts index 0fbdb58..9adb6cf 100644 --- a/test/pages.test.ts +++ b/test/pages.test.ts @@ -236,7 +236,8 @@ test('job detail shows evidence and can retry a failed job', async () => { assert.equal(repoPage.statusCode, 200) assert.match(repoPage.body, /data-goal-template/) assert.match(repoPage.body, /goal-settings/) - assert.match(repoPage.body, /data-confirm="删除这个仓库/) + assert.match(repoPage.body, /confirmName/) + assert.match(repoPage.body, /危险操作/) assert.doesNotMatch(repoPage.body, /onsubmit=|onclick=/) const page = await built.app.inject({ method: 'GET', url: `/repos/${repo.id}/jobs/${job.id}`, headers: { cookie } }) assert.equal(page.statusCode, 200) @@ -503,7 +504,7 @@ test('deleting a repository revokes its active job token', async () => { method: 'POST', url: `/repos/${repo.id}/delete`, headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, - payload: formBody({ csrf }), + payload: formBody({ csrf, confirmName: 'dt' }), }) assert.equal(deleted.statusCode, 302) assert.equal(store.getRepo(repo.id), undefined) @@ -766,3 +767,28 @@ test('a plan must be approved by a maintainer before execution', async () => { await built.close() } }) + +test('a disabled repository shows the lifecycle banner and can be enabled again', async () => { + const config = testConfig() + const store = new Store(':memory:') + const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) + try { + const user = makeUser(store, { login: 'lifecycle-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_lifecycle', userId: user.id, owner: 'o', name: 'lifecycle', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: true, intervalMinutes: 1440 }) + store.updateRepo(repo.id, { enabled: 0 }) + const { cookie, csrf } = sessionFor(built, user) + const page = await built.app.inject({ method: 'GET', url: `/repos/${repo.id}`, headers: { cookie } }) + assert.equal(page.statusCode, 200) + assert.match(page.body, /仓库已停用/) + assert.match(page.body, /仓库不可运行/) + const enabled = await built.app.inject({ method: 'POST', url: `/repos/${repo.id}/enable`, headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, payload: formBody({ csrf }) }) + assert.equal(enabled.statusCode, 302) + assert.equal(store.getRepo(repo.id)?.enabled, 1) + const disabled = await built.app.inject({ method: 'POST', url: `/repos/${repo.id}/disable`, headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, payload: formBody({ csrf }) }) + assert.equal(disabled.statusCode, 302) + assert.equal(store.getRepo(repo.id)?.enabled, 0) + assert.equal(store.getRepo(repo.id)?.auto_iterate, 0) + } finally { + await built.close() + } +})