Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/repo-lifecycle.changed.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1715,16 +1715,35 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
}
})

app.post('/repos/:id/enable', async (request, reply) => {
const { viewer, repo } = ownedRepo(request, String((request.params as Record<string, string>).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<string, string>).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<string, string>).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)
}
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) => {
Expand Down
39 changes: 32 additions & 7 deletions src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 === ''
? `<div class="banner">${escapeHtml(t('这个部署没有启用每仓库 FlyBot 实例,任务由全局 mock/runner 处理。', 'This deployment has no per-repository FlyBot instance; jobs use the global mock/runner.'))}</div>`
: instance === undefined
Expand Down Expand Up @@ -556,9 +561,9 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
<div class="row">
<a class="btn btn-ghost" href="/repos/${escapeHtml(repo.id)}/workspace">${escapeHtml(t('工作区', 'Workspace'))}</a>
${advanced ? `<a class="btn btn-ghost" href="/repos/${escapeHtml(repo.id)}/team">${escapeHtml(t('团队工作台', 'Team console'))}</a>` : ''}
${roleMissing ? `<span class="badge busy">${escapeHtml(t('先选择角色', 'Choose a role first'))}</span>` : `<form method="post" action="/repos/${escapeHtml(repo.id)}/run">${csrf(viewer)}<button class="btn" type="submit">${escapeHtml(t('立即运行', 'Run now'))}</button></form>
${roleMissing ? `<span class="badge busy">${escapeHtml(t('先选择角色', 'Choose a role first'))}</span>` : !runnable ? `<span class="badge busy">${escapeHtml(t('仓库不可运行', 'Not runnable'))}</span>` : `<form method="post" action="/repos/${escapeHtml(repo.id)}/run">${csrf(viewer)}<button class="btn" type="submit">${escapeHtml(t('立即运行', 'Run now'))}</button></form>
<form method="post" action="/repos/${escapeHtml(repo.id)}/plan">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('只出计划', 'Plan only'))}</button></form>`}
${!roleMissing && canReview && reviewerEnabled ? `<form method="post" action="/repos/${escapeHtml(repo.id)}/review">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('立即审核', 'Review now'))}</button></form>` : ''}
${!roleMissing && runnable && canReview && reviewerEnabled ? `<form method="post" action="/repos/${escapeHtml(repo.id)}/review">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('立即审核', 'Review now'))}</button></form>` : ''}
</div>
</div>
</section>`
Expand Down Expand Up @@ -663,10 +668,31 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
${repo.auto_merge === 0 && prJobs.length >= 3 ? `<div class="banner">${escapeHtml(t(`有 ${prJobs.length} 个 PR 等待合并。如果检查一直全绿,可在仓库设置里开启自动合并。`, `${prJobs.length} pull requests are waiting. If checks stay green, enable auto-merge in repository settings.`))} <a class="link" href="#repo-settings">${escapeHtml(t('去设置', 'Open settings'))}</a></div>` : ''}
<p class="muted">${escapeHtml(t('合并前会重新检查 PR 状态、checks、commit status、reviews 和 branch protection。', 'Merging re-checks the PR state, checks, commit status, reviews and branch protection.'))}</p>
<div class="table-scroll"><table><thead><tr><th>PR</th><th>${escapeHtml(t('状态', 'Status'))}</th><th>${escapeHtml(t('审核分', 'Review'))}</th><th>${escapeHtml(t('成本', 'Cost'))}</th><th>${escapeHtml(t('创建', 'Created'))}</th><th></th></tr></thead><tbody>${prRows}</tbody></table></div></section>`
const credentialBanner = repo.credential_state === 'invalid' || repo.consecutive_failures >= 3
? `<div class="banner bad">${escapeHtml(t('GitHub token 可能已失效或权限不足,自动迭代已暂停。请重新授权仓库或更新连接,然后到设置里手动重新开启。', 'The GitHub token may be invalid or lack permission; auto-iterate is paused. Re-authorize or update the connection, then enable it again in settings.'))} <a class="link" href="/auth/github/reauthorize?next=${encodeURIComponent(`/repos/${repo.id}`)}">${escapeHtml(t('重新授权 GitHub', 'Re-authorize GitHub'))}</a> · <a class="link" href="#connection">${escapeHtml(t('更新连接', 'Update connection'))}</a></div>`
: ''
const body = `${repoHeader}${roleCard}${credentialBanner}${appAttribution ? `<div class="banner">${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.'))} <a class="link" href="/auth/github/reauthorize?next=${encodeURIComponent(`/repos/${repo.id}`)}">${escapeHtml(t('重新授权 GitHub', 'Re-authorize GitHub'))}</a></div>` : ''}${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 ? `<form method="post" action="/repos/${escapeHtml(repo.id)}/enable">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('启用仓库', 'Enable repository'))}</button></form>` : '',
flybotAvailable && instance?.status !== 'ready' ? `<form method="post" action="/repos/${escapeHtml(repo.id)}/instance">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('启动 FlyBot', 'Start FlyBot'))}</button></form>` : '',
!credentialOk ? `<a class="btn btn-ghost" href="/auth/github/reauthorize?next=${encodeURIComponent(`/repos/${repo.id}`)}">${escapeHtml(t('重新授权 GitHub', 'Re-authorize GitHub'))}</a>` : '',
].join('')
const lifecycleBanner = lifecycleItems.length === 0 ? '' : `<div class="banner bad"><strong>${escapeHtml(t('当前状态', 'Current state'))}</strong><ul>${lifecycleItems.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>${lifecycleActions === '' ? '' : `<div class="row">${lifecycleActions}</div>`}</div>`
const dangerCard = `<section class="card" id="danger">
<h2>${escapeHtml(t('危险操作', 'Danger zone'))}</h2>
<p class="muted">${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.'))}</p>
<div class="row">
${repoEnabled ? `<form method="post" action="/repos/${escapeHtml(repo.id)}/disable">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('停止调度', 'Stop scheduling'))}</button></form>` : `<form method="post" action="/repos/${escapeHtml(repo.id)}/enable">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('恢复调度', 'Resume scheduling'))}</button></form>`}
${flybotAvailable ? (instance?.status === 'ready' ? `<form method="post" action="/repos/${escapeHtml(repo.id)}/instance/stop" data-confirm="${escapeHtml(t('停止这个仓库的 FlyBot 实例?正在运行的工作会中断。', 'Stop the FlyBot instance for this repository? Running work will be interrupted.'))}">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('停止 FlyBot', 'Stop FlyBot'))}</button></form>` : `<form method="post" action="/repos/${escapeHtml(repo.id)}/instance">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('启动 FlyBot', 'Start FlyBot'))}</button></form>`) : ''}
</div>
<form method="post" action="/repos/${escapeHtml(repo.id)}/delete" class="form">
${csrf(viewer)}
<label>${escapeHtml(t('输入仓库名', 'Type the repository name'))} <code>${escapeHtml(repo.name)}</code> ${escapeHtml(t('确认删除', 'to confirm deletion'))} <input name="confirmName" autocomplete="off" required /></label>
<button class="btn btn-danger" type="submit">${escapeHtml(t('删除仓库连接', 'Delete repository connection'))}</button>
</form>
</section>`
const body = `${repoHeader}${roleCard}${lifecycleBanner}${appAttribution ? `<div class="banner">${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.'))} <a class="link" href="/auth/github/reauthorize?next=${encodeURIComponent(`/repos/${repo.id}`)}">${escapeHtml(t('重新授权 GitHub', 'Re-authorize GitHub'))}</a></div>` : ''}${questionsBlock(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${instanceNotice}${instanceCard}${deliveryCard}${workCard}${dangerCard}
<section class="cols">
<div class="card" id="repo-settings">
<h2>${escapeHtml(t('迭代目标', 'Iteration goal'))}</h2>
Expand Down Expand Up @@ -732,7 +758,6 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
<div class="row" style="margin-bottom:12px">
<form method="post" action="/repos/${escapeHtml(repo.id)}/refresh">${csrf(viewer)}<button class="btn btn-ghost" type="submit">${escapeHtml(t('重新检测上游 / fork', 'Refresh upstream / fork'))}</button></form>
</div>
<form method="post" action="/repos/${escapeHtml(repo.id)}/delete" data-confirm="${escapeHtml(t('删除这个仓库及其任务记录?', 'Delete this repository and its job history?'))}">${csrf(viewer)}<button class="btn btn-danger" type="submit">${escapeHtml(t('删除仓库', 'Delete repository'))}</button></form>
<p class="fine">${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.'))}</p>
</div>
</section>
Expand Down
30 changes: 28 additions & 2 deletions test/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
})
Loading