diff --git a/changelog.d/repo-budgets.added.md b/changelog.d/repo-budgets.added.md new file mode 100644 index 0000000..445d984 --- /dev/null +++ b/changelog.d/repo-budgets.added.md @@ -0,0 +1 @@ +A repository maintainer can now set an optional rolling 30-day repository budget and a per-job alert threshold. Once the repository budget is reached, new jobs are refused with `REPO_BUDGET_EXHAUSTED`; a job that costs more than its per-job budget produces an audit entry and a quota notification. diff --git a/docs/quota.md b/docs/quota.md index 9163ccc..91cceae 100644 --- a/docs/quota.md +++ b/docs/quota.md @@ -60,6 +60,23 @@ stream ends. A request that is already in flight is allowed to finish; the } ``` +## Repository and per-job budgets + +A maintainer can set two optional caps per repository in the repository +settings: + +- **Monthly repository budget (CNY)** — a rolling 30-day cap on the repository's + attributed platform spend. A new job is refused with + `REPO_BUDGET_EXHAUSTED` when the cap is reached. +- **Per-job budget (CNY)** — an alerting threshold, not a hard stop. When a + finished job's attributed cost exceeds it, Fledge writes a + `job.budget_exceeded` audit entry and an in-app quota notification. + +Cost is attributed through `quota_ledger.repo_id`; rows written before that +column existed fall back to the job's `repo_id` when they can be joined. Both +caps default to `0` (disabled) and are recorded in the repository audit entry +when changed. + ## Own model `POST /settings/model` stores: diff --git a/src/app.ts b/src/app.ts index 0d9674f..3a60782 100644 --- a/src/app.ts +++ b/src/app.ts @@ -883,6 +883,7 @@ export async function buildApp(options: BuildOptions): Promise { instance, jobs: store.listJobsPageForRepo(repo.id, 10, (jobsPage - 1) * 10), workItems: store.listWorkItems(repo.id), + repoSpendCny: store.repoUsageSince(repo.id, new Date(Date.now() - 30 * 86_400_000).toISOString()) * config.quota.usdCny, questions, chiefMessages, activeJob: store.activeJobForRepo(repo.id) !== undefined, @@ -1643,6 +1644,8 @@ export async function buildApp(options: BuildOptions): Promise { deny_paths: (body.denyPaths ?? '').trim().slice(0, 2000), merge_method: body.mergeMethod === 'merge' || body.mergeMethod === 'rebase' ? body.mergeMethod : 'squash', require_protection: body.requireProtection === '1' ? 1 : 0, + monthly_budget_cny: Math.max(0, Math.min(10000, Number(body.monthlyBudget) || 0)), + job_budget_cny: Math.max(0, Math.min(10000, Number(body.jobBudget) || 0)), auto_iterate: body.auto === '1' ? 1 : 0, enabled: body.enabled === '1' ? 1 : 0, interval_minutes: interval, diff --git a/src/db.ts b/src/db.ts index 26002f1..6d9e1fd 100644 --- a/src/db.ts +++ b/src/db.ts @@ -81,6 +81,8 @@ export interface RepoRow { deny_paths: string merge_method: string require_protection: number + monthly_budget_cny: number + job_budget_cny: number protection_state: string | null protection_checked_at: string | null auto_iterate: number @@ -328,6 +330,8 @@ CREATE TABLE IF NOT EXISTS repos ( deny_paths TEXT NOT NULL DEFAULT '', merge_method TEXT NOT NULL DEFAULT 'squash', require_protection INTEGER NOT NULL DEFAULT 1, + monthly_budget_cny REAL NOT NULL DEFAULT 0, + job_budget_cny REAL NOT NULL DEFAULT 0, protection_state TEXT, protection_checked_at TEXT, auto_iterate INTEGER NOT NULL DEFAULT 0, @@ -538,9 +542,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 > 19) { + if (previousVersion > 20) { this.db.close() - throw new Error(`database schema version ${previousVersion} is newer than this build supports (19)`) + throw new Error(`database schema version ${previousVersion} is newer than this build supports (20)`) } this.db.exec(SCHEMA) this.migrateColumns() @@ -552,7 +556,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, 19)};`) + this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 20)};`) } private migrateColumns(): void { @@ -592,6 +596,8 @@ export class Store { ['deny_paths', "TEXT NOT NULL DEFAULT ''"], ['merge_method', "TEXT NOT NULL DEFAULT 'squash'"], ['require_protection', 'INTEGER NOT NULL DEFAULT 1'], + ['monthly_budget_cny', 'REAL NOT NULL DEFAULT 0'], + ['job_budget_cny', 'REAL NOT NULL DEFAULT 0'], ['protection_state', 'TEXT'], ['protection_checked_at', 'TEXT'], ['upstream_owner', "TEXT NOT NULL DEFAULT ''"], @@ -1029,6 +1035,8 @@ export class Store { denyPaths?: string mergeMethod?: string requireProtection?: boolean + monthlyBudgetCny?: number + jobBudgetCny?: number protectionState?: string | null protectionCheckedAt?: string | null upstreamOwner?: string @@ -1051,9 +1059,9 @@ export class Store { this.run( `INSERT INTO repos (id, user_id, owner, name, default_branch, token_enc, installation_id, goal, enabled, auto_iterate, interval_minutes, created_at, upstream_owner, upstream_name, fork_owner, fork_name, upstream_default_branch, fork_default_branch, target_branch, job_mode, - repo_role, auto_merge, role_checked_at, plan_required, allow_paths, deny_paths, merge_method, require_protection, protection_state, protection_checked_at, + repo_role, auto_merge, role_checked_at, plan_required, allow_paths, deny_paths, merge_method, require_protection, monthly_budget_cny, job_budget_cny, protection_state, protection_checked_at, is_fork, fork_created, permission, can_review, can_create_issue, reviewer_enabled, review_interval_minutes, upstream_html_url, fork_html_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, input.id, input.userId, input.owner, @@ -1081,6 +1089,8 @@ export class Store { input.denyPaths ?? '', input.mergeMethod ?? 'squash', input.requireProtection === false ? 0 : 1, + input.monthlyBudgetCny ?? 0, + input.jobBudgetCny ?? 0, input.protectionState ?? null, input.protectionCheckedAt ?? null, input.isFork === true ? 1 : 0, @@ -1123,7 +1133,7 @@ export class Store { return this.get('SELECT * FROM repos WHERE user_id = ? AND lower(owner) = lower(?) AND lower(name) = lower(?)', userId, owner, name) } - updateRepo(id: string, patch: Partial>): void { + updateRepo(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(', ') @@ -1421,6 +1431,14 @@ export class Store { }) } + repoUsageSince(repoId: string, sinceIso: string): number { + const row = this.get<{ total: number | null }>( + 'SELECT SUM(q.cost_usd) AS total FROM quota_ledger q LEFT JOIN jobs j ON j.id = q.job_id WHERE COALESCE(q.repo_id, j.repo_id) = ? AND q.at >= ?', + repoId, sinceIso, + ) + return Number(row?.total ?? 0) + } + usageTotal(userId: string): number { const row = this.get<{ total: number | null }>('SELECT SUM(cost_usd) AS total FROM quota_ledger WHERE user_id = ?', userId) return Number(row?.total ?? 0) diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 84c2e8c..71d9d1e 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -238,6 +238,13 @@ export class Jobs { if (active !== undefined) return { ok: false, error: '这个仓库已有任务在运行', code: 'JOB_ACTIVE' } const user = this.store.userById(repo.user_id) if (user === undefined || user.status !== 'approved') return { ok: false, error: '用户不可用', code: 'USER_UNAVAILABLE' } + if (repo.monthly_budget_cny > 0) { + const since = new Date(Date.now() - 30 * 86_400_000).toISOString() + const spentCny = this.store.repoUsageSince(repo.id, since) * this.config.quota.usdCny + if (spentCny >= repo.monthly_budget_cny) { + return { ok: false, error: '仓库月度预算已用完。', code: 'REPO_BUDGET_EXHAUSTED' } + } + } const repoBaseBranch = repo.target_branch !== '' ? repo.target_branch : (repo.upstream_default_branch || repo.default_branch) const baseBranch = (options.baseBranch ?? repoBaseBranch).trim() @@ -448,6 +455,14 @@ export class Jobs { if (result.status === 'failed') this.store.notify(job.user_id, 'job', `${job.kind === 'review' ? '审核' : '任务'} ${jobId} 失败:${result.error ?? job.error ?? '未知原因'}`) if (result.status === 'succeeded') void this.postAutomationComment(job, { prUrl: prUrl ?? undefined, issueUrl: issueUrl ?? undefined }, attribution) if (result.status === 'succeeded' || result.status === 'failed') void this.sendJobEmail(job, result) + const repo = this.store.getRepo(job.repo_id) + if (repo !== undefined && repo.job_budget_cny > 0) { + const jobCostCny = this.store.usageForJob(jobId) * this.config.quota.usdCny + if (jobCostCny > repo.job_budget_cny) { + this.store.audit(job.user_id, 'job.budget_exceeded', `${jobId}: ${jobCostCny.toFixed(4)} > ${repo.job_budget_cny}`) + this.store.notify(job.user_id, 'quota', `任务 ${jobId} 成本 ${jobCostCny.toFixed(2)} 元,超过单任务预算 ${repo.job_budget_cny.toFixed(2)} 元。`) + } + } void this.captureTeamStats(jobId) } diff --git a/src/views/pages.ts b/src/views/pages.ts index ea1592e..878ebb0 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -459,9 +459,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[]; 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[]; workItems?: WorkItemRow[]; 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 repoSpendCny = input.repoSpendCny ?? 0 const questions = input.questions ?? [] const chiefMessages = input.chiefMessages ?? [] const jobsPage = input.jobsPage ?? 1 @@ -679,6 +680,11 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer

${escapeHtml(t('Branch protection', 'Branch protection'))}: ${escapeHtml(repo.protection_state ?? 'unknown')}${repo.protection_checked_at === null ? '' : ` · ${escapeHtml(humanTimeShort(repo.protection_checked_at))}`}

+
+ + +
+

${escapeHtml(t('本月已用', 'Spent this month'))} ${escapeHtml(quotaMoney(repoSpendCny))}${repo.monthly_budget_cny > 0 ? ` / ${escapeHtml(quotaMoney(repo.monthly_budget_cny))}` : ''}

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

${escapeHtml(t('保存目标后,团队 playbook 会按新目标更新:总控 → 负责人 → 产品/测试/安全/审核/开发。审核角色只有勾选“启用 PR 审核”后才会参与。', 'Saving the goal updates the team playbook: Chief -> Lead -> Product/Test/Security/Reviewer/Dev. The Reviewer takes part only when "Enable PR reviewer" is checked.'))}

diff --git a/test/db.test.ts b/test/db.test.ts index 12fd77d..a9b572b 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 = 20;') + reopened.db.exec('PRAGMA user_version = 21;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) diff --git a/test/jobs.test.ts b/test/jobs.test.ts index d65e46c..6a55313 100644 --- a/test/jobs.test.ts +++ b/test/jobs.test.ts @@ -817,3 +817,28 @@ test('auto-merge is skipped when branch protection is unavailable', async () => await github.close() } }) + +test('repository monthly and per-job budgets are enforced and attributed', async () => { + const config = testConfig() + const store = new Store(':memory:') + const jobs = new Jobs(store, config, { name: 'noop', async run() { return { status: 'running' } } }, new MemoryMailer()) + try { + const user = makeUser(store, { login: 'budget-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_budget', userId: user.id, owner: 'o', name: 'b', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', monthlyBudgetCny: 1, jobBudgetCny: 0.01, goal: '', autoIterate: false, intervalMinutes: 1440 }) + store.recordUsage(user.id, null, 0.2, null, repo.id) + assert.equal(store.repoUsageSince(repo.id, '2000-01-01T00:00:00.000Z'), 0.2) + const blocked = await jobs.enqueue(repo, 'manual') + assert.equal(blocked.ok, false) + assert.equal(blocked.code, 'REPO_BUDGET_EXHAUSTED') + + const cheap = store.createRepo({ id: 'repo_budget_job', userId: user.id, owner: 'o', name: 'bj', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', jobBudgetCny: 0.01, goal: '', autoIterate: false, intervalMinutes: 1440 }) + const secret = 'budget-job-secret' + store.createJob({ id: 'job_budget', repoId: cheap.id, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: sha256Hex(secret), platformTokenHash: '', kind: 'iterate', baseBranch: 'main' }) + store.updateJob('job_budget', { status: 'running', started_at: new Date().toISOString() }) + store.recordUsage(user.id, 'job_budget', 0.02, null, cheap.id) + await jobs.handleCallback('job_budget', secret, { status: 'succeeded', summary: 'done' }) + assert.equal(store.listNotifications(user.id).some((entry) => entry.message.includes('超过单任务预算')), true) + } finally { + store.close() + } +})