From 2c6999dc18a7b76cb89445c7c33b252201d72aa0 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Thu, 5 Nov 2026 14:52:43 +0800 Subject: [PATCH] feat: add a PR delivery list and maintainer merge Show iterate jobs with open PRs on the repository page, with review score, cost, age and a maintainer merge that re-checks the tip and updates the work item. Track PR state from Webhooks and suggest auto-merge when PRs pile up. --- changelog.d/delivery-merge.added.md | 1 + docs/architecture.md | 9 ++++ src/app.ts | 18 ++++++- src/db.ts | 19 +++++-- src/jobs/service.ts | 81 ++++++++++++++++++----------- src/views/pages.ts | 15 +++++- test/db.test.ts | 17 +++++- test/db.test.ts.bak | 23 +++++++- test/jobs.test.ts | 15 ++++++ 9 files changed, 158 insertions(+), 40 deletions(-) create mode 100644 changelog.d/delivery-merge.added.md diff --git a/changelog.d/delivery-merge.added.md b/changelog.d/delivery-merge.added.md new file mode 100644 index 0000000..05e4343 --- /dev/null +++ b/changelog.d/delivery-merge.added.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index cad575e..b3119a7 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. +### 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 diff --git a/src/app.ts b/src/app.ts index 811599b..6543717 100644 --- a/src/app.ts +++ b/src/app.ts @@ -895,6 +895,7 @@ export async function buildApp(options: BuildOptions): Promise { 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), @@ -1324,6 +1325,18 @@ export async function buildApp(options: BuildOptions): Promise { 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).id), true) + const job = store.getJob(String((request.params as Record).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).id), true) const job = store.getJob(String((request.params as Record).jobId)) @@ -1448,7 +1461,7 @@ export async function buildApp(options: BuildOptions): Promise { }) app.all('/repos/:id/workspace/app', async (request, reply) => { - const { viewer, repo } = ownedRepo(request, String((request.params as Record).id)) + const { repo } = ownedRepo(request, String((request.params as Record).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}`) @@ -1463,7 +1476,7 @@ export async function buildApp(options: BuildOptions): Promise { }) app.all('/repos/:id/workspace/app/*', async (request, reply) => { - const { viewer, repo } = ownedRepo(request, String((request.params as Record).id)) + const { repo } = ownedRepo(request, String((request.params as Record).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/` @@ -2284,6 +2297,7 @@ export async function buildApp(options: BuildOptions): Promise { 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 }) diff --git a/src/db.ts b/src/db.ts index 695c790..824178a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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 @@ -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, @@ -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() @@ -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 { @@ -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)) @@ -1358,6 +1361,14 @@ export class Store { return this.all('SELECT * FROM jobs WHERE repo_id = ? ORDER BY created_at DESC LIMIT ?', repoId, limit) } + listPrJobsForRepo(repoId: string, limit = 20): JobRow[] { + return this.all("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('SELECT * FROM jobs WHERE repo_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', repoId, limit, offset) } @@ -1444,7 +1455,7 @@ export class Store { return this.all("SELECT * FROM jobs WHERE status = 'queued' ORDER BY priority DESC, created_at ASC") } - updateJob(id: string, patch: Partial>): void { + updateJob(id: string, patch: Partial>): void { const entries = Object.entries(patch).filter(([, value]) => value !== undefined) if (entries.length === 0) return const sets = entries.map(([key]) => `${key} = ?`).join(', ') diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 3fea997..c20593d 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -611,19 +611,23 @@ export class Jobs { return Number.isFinite(at) && Date.now() - at <= maxAgeMs } - private async maybeAutoMerge(job: JobRow, prUrl: string): Promise { - 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 @@ -631,24 +635,28 @@ export class Jobs { 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() @@ -656,27 +664,27 @@ export class Jobs { 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。' } } } } @@ -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 { + 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 { const match = /\/pull\/(\d+)(?:[/?#]|$)/.exec(prUrl) if (match === null) return undefined diff --git a/src/views/pages.ts b/src/views/pages.ts index 5ed7ff2..e7f8533 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -460,9 +460,10 @@ export function dashboardPage(input: { return layout({ title: t('仓库', 'Repositories'), viewer, body, config, locale, path: '/dashboard', active: 'dashboard', notice, error }) } -export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer; repo: RepoRow; instance?: RepoInstanceRow; jobs: JobRow[]; workItems?: WorkItemRow[]; models?: UserModelRow[]; repoModelIds?: string[]; repoDefaultModelId?: string; repoSpendCny?: number; questions?: InboxQuestion[]; chiefMessages?: ChiefMessage[]; activeJob?: boolean; jobsPage?: number; jobsTotal?: number; notice?: string; error?: string }): string { +export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer; repo: RepoRow; instance?: RepoInstanceRow; jobs: JobRow[]; prJobs?: JobRow[]; workItems?: WorkItemRow[]; models?: UserModelRow[]; repoModelIds?: string[]; repoDefaultModelId?: string; repoSpendCny?: number; questions?: InboxQuestion[]; chiefMessages?: ChiefMessage[]; activeJob?: boolean; jobsPage?: number; jobsTotal?: number; notice?: string; error?: string }): string { const { config, locale, viewer, repo, instance, jobs, notice, error } = input const workItems = input.workItems ?? [] + const prJobs = input.prJobs ?? [] const models = input.models ?? [] const repoModelIds = new Set(input.repoModelIds ?? []) const repoDefaultModelId = input.repoDefaultModelId ?? '' @@ -652,10 +653,20 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
${workRows}
${escapeHtml(t('工作项', 'Work item'))}${escapeHtml(t('状态', 'State'))}${escapeHtml(t('优先级', 'Priority'))}Issue / PR${escapeHtml(t('任务', 'Job'))}
`; + const prRows = prJobs.length === 0 + ? `${escapeHtml(t('没有待处理的 PR。', 'No pull requests waiting.'))}` + : prJobs.map((job) => { + const number = /\/pull\/(\d+)/.exec(job.pr_url ?? '')?.[1] ?? '' + return `#${escapeHtml(number)}${badge(t, job.status)}${job.review_score === null ? '—' : job.review_score}${escapeHtml(quotaMoney(job.cost_usd * config.quota.usdCny))}${escapeHtml(humanTimeShort(job.created_at))}${repo.repo_role === 'maintainer' ? `
${csrf(viewer)}
` : ''}` + }).join('') + const deliveryCard = `

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

+ ${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}${workCard} + const body = `${repoHeader}${roleCard}${credentialBanner}${appAttribution ? `` : ''}${questionsBlock(locale, viewer, questions, `/repos/${repo.id}`)}${chiefCard}${instanceNotice}${instanceCard}${deliveryCard}${workCard}

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

diff --git a/test/db.test.ts b/test/db.test.ts index ec6a6f5..aeb1f38 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -150,7 +150,7 @@ test('the role backfill runs once and future schema versions are refused', () => const reopened = new Store(path) assert.equal(reopened.getRepo(repo.id)?.repo_role, 'maintainer') assert.notEqual(reopened.getRepo(repo.id)?.role_checked_at, null) - reopened.db.exec('PRAGMA user_version = 24;') + reopened.db.exec('PRAGMA user_version = 25;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) @@ -276,3 +276,18 @@ test('stalled jobs and repository failure counters are persisted', () => { store.close() } }) + +test('pull request jobs disappear from the delivery list after merge', () => { + const store = new Store(':memory:') + try { + const user = makeUser(store, { login: 'delivery-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_delivery', userId: user.id, owner: 'o', name: 'd', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + store.createJob({ id: 'job_delivery', repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: 'cb', platformTokenHash: '', kind: 'iterate' }) + store.updateJob('job_delivery', { pr_url: 'https://github.com/o/d/pull/1' }) + assert.equal(store.listPrJobsForRepo(repo.id).length, 1) + store.updateJobsPrState('https://github.com/o/d/pull/1', 'merged') + assert.equal(store.listPrJobsForRepo(repo.id).length, 0) + } finally { + store.close() + } +}) diff --git a/test/db.test.ts.bak b/test/db.test.ts.bak index 6b22f22..ec6a6f5 100644 --- a/test/db.test.ts.bak +++ b/test/db.test.ts.bak @@ -150,7 +150,7 @@ test('the role backfill runs once and future schema versions are refused', () => const reopened = new Store(path) assert.equal(reopened.getRepo(repo.id)?.repo_role, 'maintainer') assert.notEqual(reopened.getRepo(repo.id)?.role_checked_at, null) - reopened.db.exec('PRAGMA user_version = 23;') + reopened.db.exec('PRAGMA user_version = 24;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) @@ -255,3 +255,24 @@ test('user model connections and repository selections persist', () => { store.close() } }) + +test('stalled jobs and repository failure counters are persisted', () => { + const store = new Store(':memory:') + try { + const user = makeUser(store, { login: 'stall-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_stall', userId: user.id, owner: 'o', name: 's', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: true, intervalMinutes: 1440 }) + store.createJob({ id: 'job_stall', repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: 'cb', platformTokenHash: '', kind: 'iterate' }) + store.updateJob('job_stall', { status: 'running', started_at: '2000-01-01T00:00:00.000Z' }) + assert.equal(store.stalledRunningJobs(new Date().toISOString()).length, 1) + store.touchJobCallback('job_stall') + assert.notEqual(store.getJob('job_stall')?.last_callback_at, null) + assert.equal(store.incrementRepoFailures(repo.id), 1) + assert.equal(store.incrementRepoFailures(repo.id), 2) + store.resetRepoFailures(repo.id) + assert.equal(store.getRepo(repo.id)?.consecutive_failures, 0) + store.markRepoCredential(repo.id, 'invalid') + assert.equal(store.getRepo(repo.id)?.credential_state, 'invalid') + } finally { + store.close() + } +}) diff --git a/test/jobs.test.ts b/test/jobs.test.ts index cb5763c..4a3e154 100644 --- a/test/jobs.test.ts +++ b/test/jobs.test.ts @@ -1007,3 +1007,18 @@ test('a credential 403 pauses the repository before enqueue', async () => { await github.close() } }) + +test('manual merge rejects a job without a pull request', async () => { + const store = new Store(':memory:') + const jobs = new Jobs(store, testConfig(), { name: 'noop', async run() { return { status: 'running' } } }, new MemoryMailer()) + try { + const user = makeUser(store, { login: 'merge-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_merge', userId: user.id, owner: 'o', name: 'm', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + const job = store.createJob({ id: 'job_merge', repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: 'cb', platformTokenHash: '', kind: 'iterate' }) + const result = await jobs.manualMerge(repo, job) + assert.equal(result.ok, false) + assert.match(result.message ?? '', /没有 PR/) + } finally { + store.close() + } +})