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-budgets.added.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions docs/quota.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,7 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
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,
Expand Down Expand Up @@ -1643,6 +1644,8 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
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,
Expand Down
30 changes: 24 additions & 6 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -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 ''"],
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1123,7 +1133,7 @@ export class Store {
return this.get<RepoRow>('SELECT * FROM repos WHERE user_id = ? AND lower(owner) = lower(?) AND lower(name) = lower(?)', userId, owner, name)
}

updateRepo(id: string, patch: Partial<Pick<RepoRow, 'goal' | 'enabled' | 'auto_iterate' | 'interval_minutes' | 'last_run_at' | 'last_status' | 'default_branch' | 'repo_role' | 'auto_merge' | 'role_checked_at' | 'plan_required' | 'allow_paths' | 'deny_paths' | 'merge_method' | 'require_protection' | 'protection_state' | 'protection_checked_at' | 'permission' | 'can_review' | 'can_create_issue' | 'reviewer_enabled' | 'review_interval_minutes' | 'last_review_at' | 'last_review_status' | 'target_branch' | 'is_fork' | 'fork_created' | 'upstream_owner' | 'upstream_name' | 'fork_owner' | 'fork_name' | 'upstream_default_branch' | 'fork_default_branch' | 'upstream_html_url' | 'fork_html_url'>>): void {
updateRepo(id: string, patch: Partial<Pick<RepoRow, 'goal' | 'enabled' | 'auto_iterate' | 'interval_minutes' | 'last_run_at' | 'last_status' | 'default_branch' | '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' | 'permission' | 'can_review' | 'can_create_issue' | 'reviewer_enabled' | 'review_interval_minutes' | 'last_review_at' | 'last_review_status' | 'target_branch' | 'is_fork' | 'fork_created' | 'upstream_owner' | 'upstream_name' | 'fork_owner' | 'fork_name' | 'upstream_default_branch' | 'fork_default_branch' | 'upstream_html_url' | 'fork_html_url'>>): void {
const entries = Object.entries(patch).filter(([, value]) => value !== undefined)
if (entries.length === 0) return
const sets = entries.map(([key]) => `${key} = ?`).join(', ')
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}

Expand Down
8 changes: 7 additions & 1 deletion src/views/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -679,6 +680,11 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer
<label class="check"><input type="checkbox" name="requireProtection" value="1" ${repo.require_protection === 1 ? 'checked' : ''} /> ${escapeHtml(t('自动合并前要求 branch protection', 'Require branch protection before auto-merge'))}${infoTip(t('开启后 Fledge 会检查保护规则、required checks 和 required approvals;读不到保护规则时不会自动合并。', 'When on, Fledge checks the protection rule, required checks and required approvals; if the rule cannot be read, it will not auto-merge.'))}</label>
</div>
<p class="fine">${escapeHtml(t('Branch protection', 'Branch protection'))}: <code>${escapeHtml(repo.protection_state ?? 'unknown')}</code>${repo.protection_checked_at === null ? '' : ` · ${escapeHtml(humanTimeShort(repo.protection_checked_at))}`}</p>
<div class="row">
<label>${escapeHtml(t('仓库月度预算(元)', 'Monthly repository budget (CNY)'))} <input name="monthlyBudget" type="number" min="0" max="10000" step="0.01" value="${repo.monthly_budget_cny.toFixed(2)}" /></label>
<label>${escapeHtml(t('单任务预算(元)', 'Per-job budget (CNY)'))} <input name="jobBudget" type="number" min="0" max="10000" step="0.01" value="${repo.job_budget_cny.toFixed(2)}" /></label>
</div>
<p class="fine">${escapeHtml(t('本月已用', 'Spent this month'))} ${escapeHtml(quotaMoney(repoSpendCny))}${repo.monthly_budget_cny > 0 ? ` / ${escapeHtml(quotaMoney(repo.monthly_budget_cny))}` : ''}</p>
${roleMissing ? `<p class="fine">${escapeHtml(t('先选择你的角色,审核和自动合并才会生效。', 'Choose your role before review or auto-merge can take effect.'))}</p>` : !githubCanReview ? `<p class="fine">${escapeHtml(t('你在这个上游没有 admin/maintain 权限,审核角色不可用。', 'You do not have admin/maintain permission on this upstream, so the reviewer role is unavailable.'))}</p>` : !canMaintain ? `<p class="fine">${escapeHtml(t('developer 不能启用 PR 审核或自动合并;切换为 maintainer 后才可用。', 'A developer cannot enable PR review or auto-merge; switch to maintainer first.'))}</p>` : ''}
${advanced ? `<details class="fine advanced-help"><summary>${escapeHtml(t('团队与调度规则', 'Team and scheduling rules'))}</summary>
<p>${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.'))}</p>
Expand Down
2 changes: 1 addition & 1 deletion test/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
25 changes: 25 additions & 0 deletions test/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
Loading