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/delivery-merge.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Repository pages now show a delivery list of iterate jobs with open pull requests, including review score, cost, age and a maintainer one-click merge. Merging re-checks the PR state, checks, commit status, reviews and branch protection, updates the linked work item, and records the PR state; a webhook keeps it in sync. The page suggests enabling auto-merge when three PRs are waiting.
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.

### Pull-request delivery

Each repository page has a delivery list of iterate jobs with open pull requests,
with review score, cost, age and a maintainer one-click merge. Merging re-checks
the PR state, checks, commit status, reviews and branch protection through the
same path as auto-merge, updates the linked work item and marks the job's PR
state. A webhook updates the PR state when a PR is merged or closed. When at
least three PRs are waiting and auto-merge is off, the page suggests enabling it.

### Job health and credential circuit breaker

Every callback records `jobs.last_callback_at`. A running job with no callback for
Expand Down
18 changes: 16 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,7 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
repo,
instance,
jobs: store.listJobsPageForRepo(repo.id, 10, (jobsPage - 1) * 10),
prJobs: store.listPrJobsForRepo(repo.id, 10),
workItems: store.listWorkItems(repo.id),
models: store.listUserModels(repo.user_id),
repoModelIds: repoModels.map((model) => model.id),
Expand Down Expand Up @@ -1324,6 +1325,18 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
return reply.redirect(`/repos/${repo.id}/jobs/${result.job.id}?notice=${encodeURIComponent('已重新排队。')}`)
})

app.post('/repos/:id/jobs/:jobId/merge', async (request, reply) => {
const { viewer, repo } = ownedRepo(request, String((request.params as Record<string, string>).id), true)
const job = store.getJob(String((request.params as Record<string, string>).jobId))
if (job === undefined || job.repo_id !== repo.id) throw httpError(404, '任务不存在。', 'JOB_NOT_FOUND')
if (repo.repo_role !== 'maintainer') return reply.redirect(`/repos/${repo.id}/jobs/${job.id}?error=${encodeURIComponent('只有 maintainer 可以合并 PR。')}`)
const result = await jobs.manualMerge(repo, job)
store.audit(viewer.user.id, 'job.merge_requested', `${job.id}: ${result.ok ? 'merged' : (result.message ?? 'failed')}`)
const key = result.ok ? 'notice' : 'error'
const message = result.ok ? 'PR 已合并。' : (result.message ?? '合并失败。')
return reply.redirect(`/repos/${repo.id}/jobs/${job.id}?${key}=${encodeURIComponent(message)}`)
})

app.post('/repos/:id/jobs/:jobId/approve-plan', async (request, reply) => {
const { viewer, repo } = ownedRepo(request, String((request.params as Record<string, string>).id), true)
const job = store.getJob(String((request.params as Record<string, string>).jobId))
Expand Down Expand Up @@ -1448,7 +1461,7 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
})

app.all('/repos/:id/workspace/app', async (request, reply) => {
const { viewer, repo } = ownedRepo(request, String((request.params as Record<string, string>).id))
const { repo } = ownedRepo(request, String((request.params as Record<string, string>).id))
const url = request.raw.url ?? ''
const query = url.includes('?') ? url.slice(url.indexOf('?')) : ''
if (!url.split('?')[0]?.endsWith('/')) return reply.redirect(`/repos/${repo.id}/workspace/app/${query}`)
Expand All @@ -1463,7 +1476,7 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
})

app.all('/repos/:id/workspace/app/*', async (request, reply) => {
const { viewer, repo } = ownedRepo(request, String((request.params as Record<string, string>).id))
const { repo } = ownedRepo(request, String((request.params as Record<string, string>).id))
const instance = store.getRepoInstance(repo.id)
if (instance === undefined || instance.status !== 'ready') return reply.code(409).send({ error: 'FlyBot 实例尚未就绪' })
const prefix = `/repos/${repo.id}/workspace/app/`
Expand Down Expand Up @@ -2284,6 +2297,7 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
const title = typeof pull.title === 'string' ? pull.title.slice(0, 200) : prUrl
const merged = pull.merged === true
const closed = action === 'closed' || pull.state === 'closed'
store.updateJobsPrState(prUrl, merged ? 'merged' : closed ? 'closed' : 'open')
if (existing === undefined && action !== 'opened') return
if (existing === undefined) {
store.createWorkItem({ id: randomId('work'), repoId: repo.id, userId: repo.user_id, title, prUrl, state: merged ? 'merged' : closed ? 'closed' : 'pr_ready', prState: merged ? 'merged' : closed ? 'closed' : 'open', mergedAt: merged ? nowIso() : null, mergeCommit: typeof pull.merge_commit_sha === 'string' ? pull.merge_commit_sha : null })
Expand Down
19 changes: 15 additions & 4 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export interface JobRow {
callback_hash: string
kind: string
issue_url: string | null
pr_state: string | null
review_score: number | null
attribution_json: string | null
evidence_json: string | null
Expand Down Expand Up @@ -431,6 +432,7 @@ CREATE TABLE IF NOT EXISTS jobs (
callback_hash TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'iterate',
issue_url TEXT,
pr_state TEXT,
review_score INTEGER,
attribution_json TEXT,
evidence_json TEXT,
Expand Down Expand Up @@ -592,9 +594,9 @@ export class Store {
this.db.exec('PRAGMA foreign_keys = ON;')
this.db.exec('PRAGMA busy_timeout = 5000;')
const previousVersion = Number(this.get<{ user_version: number }>('PRAGMA user_version')?.user_version ?? 0)
if (previousVersion > 23) {
if (previousVersion > 24) {
this.db.close()
throw new Error(`database schema version ${previousVersion} is newer than this build supports (23)`)
throw new Error(`database schema version ${previousVersion} is newer than this build supports (24)`)
}
this.db.exec(SCHEMA)
this.migrateColumns()
Expand All @@ -606,7 +608,7 @@ export class Store {
if (previousVersion < 16) {
this.run('UPDATE repos SET role_checked_at = ? WHERE repo_role IS NOT NULL AND role_checked_at IS NULL', nowIso())
}
this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 23)};`)
this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 24)};`)
}

private migrateColumns(): void {
Expand Down Expand Up @@ -700,6 +702,7 @@ export class Store {
if (!jobColumns.has('plan_deny_paths')) this.run('ALTER TABLE jobs ADD COLUMN plan_deny_paths TEXT')
if (!jobColumns.has('base_sha')) this.run('ALTER TABLE jobs ADD COLUMN base_sha TEXT')
if (!jobColumns.has('last_callback_at')) this.run('ALTER TABLE jobs ADD COLUMN last_callback_at TEXT')
if (!jobColumns.has('pr_state')) this.run('ALTER TABLE jobs ADD COLUMN pr_state TEXT')
const tokenColumns = new Set(this.all<{ name: string }>('PRAGMA table_info(api_tokens)').map((row) => row.name))
if (!tokenColumns.has('repo_id')) this.run('ALTER TABLE api_tokens ADD COLUMN repo_id TEXT')
const ledgerColumns = new Set(this.all<{ name: string }>('PRAGMA table_info(quota_ledger)').map((row) => row.name))
Expand Down Expand Up @@ -1358,6 +1361,14 @@ export class Store {
return this.all<JobRow>('SELECT * FROM jobs WHERE repo_id = ? ORDER BY created_at DESC LIMIT ?', repoId, limit)
}

listPrJobsForRepo(repoId: string, limit = 20): JobRow[] {
return this.all<JobRow>("SELECT * FROM jobs WHERE repo_id = ? AND pr_url IS NOT NULL AND kind = 'iterate' AND (pr_state IS NULL OR pr_state != 'merged') ORDER BY created_at DESC LIMIT ?", repoId, limit)
}

updateJobsPrState(prUrl: string, state: string): void {
this.run('UPDATE jobs SET pr_state = ? WHERE pr_url = ?', state, prUrl)
}

listJobsPageForRepo(repoId: string, limit: number, offset: number): JobRow[] {
return this.all<JobRow>('SELECT * FROM jobs WHERE repo_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', repoId, limit, offset)
}
Expand Down Expand Up @@ -1444,7 +1455,7 @@ export class Store {
return this.all<JobRow>("SELECT * FROM jobs WHERE status = 'queued' ORDER BY priority DESC, created_at ASC")
}

updateJob(id: string, patch: Partial<Pick<JobRow, 'status' | 'branch' | 'pr_url' | 'issue_url' | 'review_score' | 'attribution_json' | 'evidence_json' | 'checkpoint' | 'team_stats_json' | 'summary' | 'error' | 'cost_usd' | 'started_at' | 'last_callback_at' | 'finished_at' | 'base_branch' | 'work_item_id' | 'priority' | 'plan_approved_at' | 'plan_approved_by' | 'plan_rejected_at' | 'plan_allow_paths' | 'plan_deny_paths' | 'base_sha'>>): void {
updateJob(id: string, patch: Partial<Pick<JobRow, 'status' | 'branch' | 'pr_url' | 'issue_url' | 'pr_state' | 'review_score' | 'attribution_json' | 'evidence_json' | 'checkpoint' | 'team_stats_json' | 'summary' | 'error' | 'cost_usd' | 'started_at' | 'last_callback_at' | 'finished_at' | 'base_branch' | 'work_item_id' | 'priority' | 'plan_approved_at' | 'plan_approved_by' | 'plan_rejected_at' | 'plan_allow_paths' | 'plan_deny_paths' | 'base_sha'>>): void {
const entries = Object.entries(patch).filter(([, value]) => value !== undefined)
if (entries.length === 0) return
const sets = entries.map(([key]) => `${key} = ?`).join(', ')
Expand Down
81 changes: 51 additions & 30 deletions src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,72 +611,80 @@ export class Jobs {
return Number.isFinite(at) && Date.now() - at <= maxAgeMs
}

private async maybeAutoMerge(job: JobRow, prUrl: string): Promise<void> {
if (job.kind !== 'iterate') return
const repo = this.store.getRepo(job.repo_id)
if (repo === undefined || repo.repo_role !== 'maintainer' || repo.auto_merge !== 1) return
private async tryMerge(job: JobRow, prUrl: string, manual: boolean): Promise<{ ok: boolean; message?: string }> {
if (job.kind !== 'iterate') return { ok: false, message: '只有 iterate 任务可以合并。' }
let repo = this.store.getRepo(job.repo_id)
if (repo === undefined) return { ok: false, message: '仓库不存在。' }
if (repo.repo_role !== 'maintainer') return { ok: false, message: '只有 maintainer 可以合并。' }
if (!manual && repo.auto_merge !== 1) return { ok: false, message: '自动合并未开启。' }
if (!this.roleFresh(repo)) {
const refreshed = await this.refreshRepoAccess(repo)
if (refreshed === undefined || refreshed.repo_role !== 'maintainer' || refreshed.auto_merge !== 1) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: stale role check`)
return
if (refreshed === undefined || refreshed.repo_role !== 'maintainer') {
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: stale role check`)
return { ok: false, message: 'GitHub 权限已过期,请刷新仓库。' }
}
repo = refreshed
if (!manual && repo.auto_merge !== 1) return { ok: false, message: '自动合并未开启。' }
}
const match = /\/pull\/(\d+)(?:[/?#]|$)/.exec(prUrl)
if (match === null) return
if (match === null) return { ok: false, message: 'PR 链接格式不正确。' }
const number = Number(match[1])
const owner = repo.upstream_owner || repo.owner
const name = repo.upstream_name || repo.name
try {
const token = await repoGithubToken(repo, this.config, this.githubApp, this.store)
const client = new GithubClient(token, this.config.github.apiBase)
const pr = await client.getPullRequest(owner, name, number)
if (pr.state !== 'open' || pr.draft || pr.merged) return
if (pr.merged) {
this.store.updateJobsPrState(prUrl, 'merged')
return { ok: false, message: 'PR 已合并。' }
}
if (pr.state !== 'open' || pr.draft) return { ok: false, message: 'PR 不是 open 状态。' }
if (pr.mergeable === false || ['blocked', 'dirty', 'behind', 'unknown'].includes(pr.mergeableState)) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: PR ${number} is ${pr.mergeableState}`)
return
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: PR ${number} is ${pr.mergeableState}`)
return { ok: false, message: `PR 当前不可合并(${pr.mergeableState})。` }
}
const checks = await client.listCheckRuns(owner, name, pr.headSha)
if (checks.length === 0) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: no check runs`)
return
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: no check runs`)
return { ok: false, message: 'PR 还没有 check runs。' }
}
if (checks.some((run) => run.status !== 'completed' || !['success', 'neutral', 'skipped'].includes(run.conclusion ?? ''))) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: checks are not green`)
return
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: checks are not green`)
return { ok: false, message: 'PR 检查还没有全绿。' }
}
const combined = await client.getCombinedStatus(owner, name, pr.headSha)
if (combined.count > 0 && combined.state !== 'success') {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: commit status is ${combined.state}`)
return
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: commit status is ${combined.state}`)
return { ok: false, message: `commit status 是 ${combined.state}。` }
}
const reviews = await client.listReviews(owner, name, number)
const latest = new Map<string, string>()
for (const review of reviews.sort((a, b) => a.submittedAt.localeCompare(b.submittedAt))) {
if (review.user !== '') latest.set(review.user, review.state)
}
if ([...latest.values()].includes('CHANGES_REQUESTED')) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: changes requested`)
return
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: changes requested`)
return { ok: false, message: 'PR 还有 changes requested。' }
}
if (repo.require_protection === 1) {
const protection = await client.getBranchProtection(owner, name, pr.base)
if (protection === null || !protection.protected) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: branch protection unavailable`)
return
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: branch protection unavailable`)
return { ok: false, message: '读不到 branch protection。' }
}
if (protection.requiredChecks.length > 0) {
const passing = new Set(checks.filter((run) => run.status === 'completed' && ['success', 'neutral', 'skipped'].includes(run.conclusion ?? '')).map((run) => run.name))
if (!protection.requiredChecks.every((context) => passing.has(context))) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: required checks missing`)
return
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: required checks missing`)
return { ok: false, message: '缺少 required checks。' }
}
}
if (protection.requiredApprovals > 0) {
const approvals = [...latest.values()].filter((state) => state === 'APPROVED').length
if (approvals < protection.requiredApprovals) {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: required approvals missing`)
return
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: required approvals missing`)
return { ok: false, message: '缺少 required approvals。' }
}
}
}
Expand All @@ -687,17 +695,30 @@ export class Jobs {
if (workItem !== undefined) {
this.store.updateWorkItem(workItem.id, { state: 'merged', pr_state: 'merged', merged_at: nowIso(), merge_commit: merged.sha ?? null })
}
this.store.audit(job.user_id, 'job.auto_merged', `${job.id}: PR ${number} ${merged.sha ?? ''}`)
this.store.updateJobsPrState(prUrl, 'merged')
this.store.audit(job.user_id, manual ? 'job.manually_merged' : 'job.auto_merged', `${job.id}: PR ${number} ${merged.sha ?? ''}`)
this.store.event('job_auto_merged', { userId: job.user_id, repoId: job.repo_id, jobId: job.id, meta: `pr=${number}` })
} else {
this.store.audit(job.user_id, 'job.auto_merge_skipped', `${job.id}: ${merged.message ?? 'merge rejected'}`)
return { ok: true, message: '已合并。' }
}
this.store.audit(job.user_id, 'job.merge_skipped', `${job.id}: ${merged.message ?? 'merge rejected'}`)
return { ok: false, message: merged.message ?? 'GitHub 拒绝了合并。' }
} catch (error) {
this.store.audit(job.user_id, 'job.auto_merge_failed', `${job.id}: ${String(error).slice(0, 300)}`)
log.warn('auto-merge failed', { job: job.id, pr: number, error: String(error) })
this.store.audit(job.user_id, 'job.merge_failed', `${job.id}: ${String(error).slice(0, 300)}`)
log.warn('merge failed', { job: job.id, pr: number, error: String(error) })
return { ok: false, message: error instanceof Error ? error.message : String(error) }
}
}

private async maybeAutoMerge(job: JobRow, prUrl: string): Promise<void> {
await this.tryMerge(job, prUrl, false)
}

async manualMerge(repo: RepoRow, job: JobRow): Promise<{ ok: boolean; message?: string }> {
if (job.repo_id !== repo.id) return { ok: false, message: '任务不属于这个仓库。' }
if (job.pr_url === null) return { ok: false, message: '这个任务没有 PR。' }
return this.tryMerge(job, job.pr_url, true)
}

private async resolveHeadSha(repo: RepoRow, prUrl: string): Promise<string | undefined> {
const match = /\/pull\/(\d+)(?:[/?#]|$)/.exec(prUrl)
if (match === null) return undefined
Expand Down
Loading
Loading