From 188cbf59dabd8dc3965be98568b3f9e3ff480009 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Wed, 4 Nov 2026 16:37:55 +0800 Subject: [PATCH 1/2] feat: attribute API usage and job tokens to repositories Store repo_id on api_tokens and quota_ledger so usage can be attributed per repository. This is internal plumbing and does not change user-visible behavior. Docs-not-needed: internal attribution columns; budgets are documented separately. --- src/app.ts | 1 + src/db.ts | 22 +++++++++++++++------- src/flybot/instances.ts | 1 + src/gateway/gateway.ts | 2 +- src/jobs/service.ts | 1 + test/db.test.ts | 17 ++++++++++++++++- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/app.ts b/src/app.ts index 64b5947..0d9674f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1765,6 +1765,7 @@ export async function buildApp(options: BuildOptions): Promise { name, tokenHash: sha256Hex(raw), prefix: raw.slice(0, 8), + repoId: null, expiresAt: null, }) store.audit(viewer.user.id, 'token.created', name) diff --git a/src/db.ts b/src/db.ts index 65d6021..26002f1 100644 --- a/src/db.ts +++ b/src/db.ts @@ -193,6 +193,7 @@ export interface ApiTokenRow { name: string token_hash: string prefix: string + repo_id: string | null expires_at: string | null revoked_at: string | null last_used_at: string | null @@ -426,6 +427,7 @@ CREATE TABLE IF NOT EXISTS api_tokens ( name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, prefix TEXT NOT NULL, + repo_id TEXT, expires_at TEXT, revoked_at TEXT, last_used_at TEXT, @@ -438,6 +440,7 @@ CREATE TABLE IF NOT EXISTS quota_ledger ( at TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, job_id TEXT, + repo_id TEXT, cost_usd REAL NOT NULL, meta TEXT ); @@ -535,9 +538,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 > 18) { + if (previousVersion > 19) { this.db.close() - throw new Error(`database schema version ${previousVersion} is newer than this build supports (18)`) + throw new Error(`database schema version ${previousVersion} is newer than this build supports (19)`) } this.db.exec(SCHEMA) this.migrateColumns() @@ -549,7 +552,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, 18)};`) + this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 19)};`) } private migrateColumns(): void { @@ -636,6 +639,10 @@ export class Store { if (!jobColumns.has('plan_allow_paths')) this.run('ALTER TABLE jobs ADD COLUMN plan_allow_paths TEXT') 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') + 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)) + if (!ledgerColumns.has('repo_id')) this.run('ALTER TABLE quota_ledger ADD COLUMN repo_id TEXT') if (!jobColumns.has('base_branch')) this.run('ALTER TABLE jobs ADD COLUMN base_branch TEXT') this.run("UPDATE jobs SET base_branch = (SELECT upstream_default_branch FROM repos WHERE repos.id = jobs.repo_id) WHERE base_branch IS NULL") } @@ -1335,15 +1342,16 @@ export class Store { return this.all("SELECT * FROM jobs WHERE status = 'queued' AND created_at < ?", beforeIso) } - createApiToken(input: { id: string; userId: string; kind: string; name: string; tokenHash: string; prefix: string; expiresAt: string | null }): ApiTokenRow { + createApiToken(input: { id: string; userId: string; kind: string; name: string; tokenHash: string; prefix: string; repoId?: string | null; expiresAt: string | null }): ApiTokenRow { this.run( - 'INSERT INTO api_tokens (id, user_id, kind, name, token_hash, prefix, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO api_tokens (id, user_id, kind, name, token_hash, prefix, repo_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', input.id, input.userId, input.kind, input.name, input.tokenHash, input.prefix, + input.repoId ?? null, input.expiresAt, nowIso(), ) @@ -1406,9 +1414,9 @@ export class Store { this.run("UPDATE api_tokens SET revoked_at = ? WHERE kind = 'job' AND (expires_at IS NOT NULL AND expires_at <= ?) AND revoked_at IS NULL", nowIso(), nowIso()) } - recordUsage(userId: string, jobId: string | null, costUsd: number, meta: string | null = null): void { + recordUsage(userId: string, jobId: string | null, costUsd: number, meta: string | null = null, repoId: string | null = null): void { this.transaction(() => { - this.run('INSERT INTO quota_ledger (at, user_id, job_id, cost_usd, meta) VALUES (?, ?, ?, ?, ?)', nowIso(), userId, jobId, costUsd, meta) + this.run('INSERT INTO quota_ledger (at, user_id, job_id, repo_id, cost_usd, meta) VALUES (?, ?, ?, ?, ?, ?)', nowIso(), userId, jobId, repoId, costUsd, meta) this.run('UPDATE users SET quota_used_usd = quota_used_usd + ? WHERE id = ?', costUsd, userId) }) } diff --git a/src/flybot/instances.ts b/src/flybot/instances.ts index c95bcf6..8284665 100644 --- a/src/flybot/instances.ts +++ b/src/flybot/instances.ts @@ -241,6 +241,7 @@ server.listen(3081, '0.0.0.0') name: `flybot:${repo.id}`, tokenHash: sha256Hex(gatewayToken), prefix: gatewayToken.slice(0, 8), + repoId: repo.id, expiresAt: null, }) diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index bee52a1..f850ce3 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -225,7 +225,7 @@ export function registerGateway(app: FastifyInstance, deps: GatewayDeps): void { function finishCost(costUsd: number, usageValue: unknown): void { if (!funded || costUsd <= 0) return - store.recordUsage(auth!.user.id, jobId, costUsd, JSON.stringify({ model: targetModel, usage: usageValue, ms: Date.now() - started })) + store.recordUsage(auth!.user.id, jobId, costUsd, JSON.stringify({ model: targetModel, usage: usageValue, ms: Date.now() - started }), auth!.token.repo_id ?? null) if (jobId !== null) store.addJobCost(jobId, costUsd) log.info('gateway usage', { user: auth!.user.login, costUsd, jobId, model: targetModel }) const updated = store.userById(auth!.user.id) diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 03b272d..84c2e8c 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -300,6 +300,7 @@ export class Jobs { name: `job:${jobId}`, tokenHash: sha256Hex(platformToken), prefix: platformToken.slice(0, 8), + repoId: repo.id, expiresAt: addMinutes(this.config.runner.timeoutMinutes + 60), }) const priorJobs = this.store.listJobs(user.id, 1).length diff --git a/test/db.test.ts b/test/db.test.ts index 323d456..12fd77d 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 = 19;') + reopened.db.exec('PRAGMA user_version = 20;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) @@ -201,3 +201,18 @@ test('work items persist, update and are found by issue or PR', () => { store.close() } }) + +test('usage and job tokens carry the repository for cost attribution', () => { + const store = new Store(':memory:') + try { + const user = makeUser(store, { login: 'attribution-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_attribution', userId: user.id, owner: 'o', name: 'a', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + store.recordUsage(user.id, null, 0.2, null, repo.id) + const usage = store.get<{ repo_id: string | null }>('SELECT repo_id FROM quota_ledger ORDER BY id DESC LIMIT 1') + assert.equal(usage?.repo_id, repo.id) + const token = store.createApiToken({ id: 'tok_attr', userId: user.id, kind: 'job', name: 'job:attr', tokenHash: 'hash_attr', prefix: 'flg', repoId: repo.id, expiresAt: null }) + assert.equal(token.repo_id, repo.id) + } finally { + store.close() + } +}) From 7fe692c32d562afc6645401162787b53a19d0aaf Mon Sep 17 00:00:00 2001 From: HugoXK Date: Wed, 4 Nov 2026 16:46:39 +0800 Subject: [PATCH 2/2] feat: add repository and per-job budgets Add an optional rolling 30-day repository budget that blocks new jobs, and a per-job alert threshold that audits and notifies after a finished job. Cost is attributed through quota_ledger.repo_id. --- changelog.d/repo-budgets.added.md | 1 + docs/quota.md | 17 +++++++++++++++++ src/app.ts | 3 +++ src/db.ts | 30 ++++++++++++++++++++++++------ src/jobs/service.ts | 15 +++++++++++++++ src/views/pages.ts | 8 +++++++- test/db.test.ts | 2 +- test/jobs.test.ts | 25 +++++++++++++++++++++++++ 8 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 changelog.d/repo-budgets.added.md 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() + } +})