Skip to content
Open
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
27 changes: 27 additions & 0 deletions packages/data-provider/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ import {
PULL_REQUEST_BATCH_TIMEOUT_MAX_SECONDS,
} from './types/pullRequest';
import { isActionTool } from './types/tools';
import {
PR_AUTOMATION_TRUST_LEVELS,
DEFAULT_PR_AUTOMATION_BOTS,
MAX_PR_AUTOMATION_BOTS,
} from './types/prAutomation';
import { apiBaseUrl } from './api-endpoints';
import { FileSources } from './types/files';
import { MCPServersSchema } from './mcp';
Expand Down Expand Up @@ -1923,6 +1928,28 @@ export const agentsEndpointSchema = baseEndpointSchema
}
})
.optional(),
/** Auto-fix CI and address review comments on a conversation's pull request. Off unless an
* administrator opts in; a user can only narrow `maxTrust`, never widen it. */
prAutomation: z
.object({
enabled: z.boolean().optional().default(false),
Comment on lines +1933 to +1935

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire the enabled automation into a user-facing flow

At this commit, setting endpoints.agents.prAutomation.enabled to true has no observable effect: a repo-wide search finds the new enable/claim operations only in their definitions and tests, while production consumers call only stop/delete during cleanup. There is no route, webhook orchestration, or frontend entry point that enables a record or starts a round, so administrators can opt into a capability that no user can invoke. Add the owning backend flow and frontend controls, or defer exposing this configuration until they ship.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 👍 / 👎.

/** Fix rounds one pull request may run before the automation stops. */
maxRounds: z.number().int().min(1).max(20).optional().default(5),
/** Wall-clock minutes from the first automated round before the automation stops. */
maxMinutes: z.number().int().min(5).max(1440).optional().default(120),
/** Bots one user may approve for one repository. */
maxBots: z
.number()
.int()
.min(1)
.max(MAX_PR_AUTOMATION_BOTS)
.optional()
.default(DEFAULT_PR_AUTOMATION_BOTS),
/** Widest author trust a user may select: `approvedBots` acts on the linked account and
* allowlisted bots only, `collaborators` adds write-access users, `anyone` acts on all. */
maxTrust: z.enum(PR_AUTOMATION_TRUST_LEVELS).optional().default('approvedBots'),
})
.optional(),
/** Conversational background-task delivery policy. Automatic completion wakeups are
* enabled unless an administrator explicitly restores poll-only behavior. */
backgroundTasks: z
Expand Down
1 change: 1 addition & 0 deletions packages/data-provider/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type {
export * from './types/subagents';
export * from './types/background';
export * from './types/pullRequest';
export * from './types/prAutomation';
export * from './types/queuedTurns';
/* access permissions */
export * from './accessPermissions';
Expand Down
80 changes: 80 additions & 0 deletions packages/data-provider/src/prAutomation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { PRAutomationTrustLevel } from './types/prAutomation';
import { clampPRAutomationTrust } from './types/prAutomation';
import { configSchema } from './config';

describe('clampPRAutomationTrust', () => {
it('defaults to the narrowest level when the user chose nothing', () => {
expect(clampPRAutomationTrust(undefined, 'anyone')).toBe('approvedBots');
});

it.each<[PRAutomationTrustLevel, PRAutomationTrustLevel, PRAutomationTrustLevel]>([
['anyone', 'approvedBots', 'approvedBots'],
['collaborators', 'approvedBots', 'approvedBots'],
['anyone', 'collaborators', 'collaborators'],
['approvedBots', 'anyone', 'approvedBots'],
['collaborators', 'anyone', 'collaborators'],
['anyone', 'anyone', 'anyone'],
])('requested %s under a %s ceiling gives %s', (requested, ceiling, expected) => {
expect(clampPRAutomationTrust(requested, ceiling)).toBe(expected);
});

it('falls back to the narrowest level for a value it does not know', () => {
expect(clampPRAutomationTrust('everyone' as unknown as PRAutomationTrustLevel, 'anyone')).toBe(
'approvedBots',
);
});
});

describe('agent PR automation config', () => {
const parse = (prAutomation?: unknown) =>
configSchema.safeParse({ version: '1.0', endpoints: { agents: { prAutomation } } });

it('is absent when not configured, which leaves it off', () => {
const absent = configSchema.parse({ version: '1.0', endpoints: { agents: {} } });
expect(absent.endpoints?.agents?.prAutomation).toBeUndefined();
});

it('defaults to disabled with the narrowest ceiling', () => {
const empty = configSchema.parse({
version: '1.0',
endpoints: { agents: { prAutomation: {} } },
});
expect(empty.endpoints?.agents?.prAutomation).toEqual({
enabled: false,
maxRounds: 5,
maxMinutes: 120,
maxBots: 20,
maxTrust: 'approvedBots',
});
});

it('accepts an administrator raising the limits and the ceiling', () => {
const result = parse({
enabled: true,
maxRounds: 10,
maxMinutes: 240,
maxTrust: 'collaborators',
});
expect(result.success).toBe(true);
});

it.each([0, 21, 1.5])('rejects %s fix rounds', (maxRounds) => {
expect(parse({ maxRounds }).success).toBe(false);
});

it.each([4, 1441, 1.5])('rejects a %s minute window', (maxMinutes) => {
expect(parse({ maxMinutes }).success).toBe(false);
});

it.each([0, 101, 1.5])('rejects %s approved bots per repository', (maxBots) => {
expect(parse({ maxBots }).success).toBe(false);
});

it('accepts the largest approved bot limit', () => {
expect(parse({ maxBots: 100 }).success).toBe(true);
});

it.each(['everyone', 'bots', ''])('rejects %s as a trust ceiling', (maxTrust) => {
expect(parse({ maxTrust }).success).toBe(false);
});
});
46 changes: 46 additions & 0 deletions packages/data-provider/src/types/prAutomation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export const PR_AUTOMATION_STATES = ['idle', 'waiting', 'fixing', 'needs_user', 'stopped'] as const;

/**
* Stable, machine-readable reasons an automation stopped. The client maps each
* code to localized copy; none of them carries provider text or a comment body.
*/
export const PR_AUTOMATION_STOP_CODES = [
'user_stopped',
'round_cap',
'time_cap',
'fork_pull_request',
'untrusted_head_commit',
'approval_expired',
'pull_request_closed',
'disabled_by_admin',
] as const;

/** Largest per-repository bot allowlist a deployment can configure. */
export const MAX_PR_AUTOMATION_BOTS = 100;
export const DEFAULT_PR_AUTOMATION_BOTS = 20;

/** Ordered from narrowest to widest: who may be acted on. */
export const PR_AUTOMATION_TRUST_LEVELS = ['approvedBots', 'collaborators', 'anyone'] as const;

export type PRAutomationState = (typeof PR_AUTOMATION_STATES)[number];
export type PRAutomationStopCode = (typeof PR_AUTOMATION_STOP_CODES)[number];
export type PRAutomationTrustLevel = (typeof PR_AUTOMATION_TRUST_LEVELS)[number];

/**
* An administrator's ceiling bounds what a user may choose. A user can only
* narrow it, so the result is whichever level is narrower.
*/
export function clampPRAutomationTrust(
requested: PRAutomationTrustLevel | undefined,
ceiling: PRAutomationTrustLevel,
): PRAutomationTrustLevel {
if (requested == null) {
return PR_AUTOMATION_TRUST_LEVELS[0];
}
const requestedRank = PR_AUTOMATION_TRUST_LEVELS.indexOf(requested);
const ceilingRank = PR_AUTOMATION_TRUST_LEVELS.indexOf(ceiling);
if (requestedRank < 0) {
return PR_AUTOMATION_TRUST_LEVELS[0];
}
return PR_AUTOMATION_TRUST_LEVELS[Math.min(requestedRank, ceilingRank)];
}
110 changes: 110 additions & 0 deletions packages/data-schemas/src/methods/conversation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from '../types';
import { ConversationMethods, createConversationMethods } from './conversation';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
import { createPRAutomationMethods } from './prAutomation';
import { createChatProjectMethods } from './chatProject';
import { createModels } from '../models';

Expand Down Expand Up @@ -3711,6 +3712,115 @@ describe('Conversation Operations', () => {
});

describe('deleteConvos', () => {
it('removes the PR automation record of a deleted conversation and keeps the others', async () => {
const conversationId = uuidv4();
const keptConversationId = uuidv4();
const PRAutomation = mongoose.models.PRAutomation;
for (const id of [conversationId, keptConversationId]) {
await Conversation.create({ conversationId: id, user: 'user123', endpoint: 'agents' });
await PRAutomation.create({ user: 'user123', conversationId: id });
}

await deleteConvos('user123', { conversationId });

expect(await PRAutomation.countDocuments({ conversationId })).toBe(0);
expect(await PRAutomation.countDocuments({ conversationId: keptConversationId })).toBe(1);
});

describe('when the conversation delete fails', () => {
const user = 'user123';
const setup = async () => {
const conversationId = uuidv4();
await Conversation.create({ conversationId, user, endpoint: EModelEndpoint.agents });
const automation = createPRAutomationMethods(mongoose);
await mongoose.models.PRAutomation.create({
user,
conversationId,
repository: 'acme/one',
pullNumber: 1,
});
jest.spyOn(Conversation, 'deleteMany').mockRejectedValueOnce(new Error('delete failed'));
await expect(deleteConvos(user, { conversationId })).rejects.toThrow('delete failed');
return { conversationId, automation };
};

it('keeps the PR automation record', async () => {
const { conversationId, automation } = await setup();
expect(await automation.getPRAutomation({ userId: user, conversationId })).not.toBeNull();
expect(await Conversation.exists({ conversationId })).not.toBeNull();
});

it('leaves the record exactly as it was, so the kept conversation keeps working', async () => {
const { conversationId, automation } = await setup();
expect(await automation.getPRAutomation({ userId: user, conversationId })).toMatchObject({
state: 'idle',
repository: 'acme/one',
pullNumber: 1,
});
});
});

it('writes nothing for a conversation that never had an automation', async () => {
const conversationId = uuidv4();
await Conversation.create({ conversationId, user: 'user123', endpoint: 'agents' });
const PRAutomation = mongoose.models.PRAutomation;
const writes = ['updateOne', 'updateMany', 'bulkWrite', 'insertMany', 'create'].map(
(method) => jest.spyOn(PRAutomation, method as 'updateOne'),
);
try {
await deleteConvos('user123', { conversationId });
for (const write of writes) {
expect(write).not.toHaveBeenCalled();
}
expect(await PRAutomation.countDocuments({ user: 'user123', conversationId })).toBe(0);
} finally {
writes.forEach((write) => write.mockRestore());
}
});

it('still reports a deletion that committed when the record cleanup keeps failing', async () => {
const conversationId = uuidv4();
await Conversation.create({ conversationId, user: 'user123', endpoint: 'agents' });
const PRAutomation = mongoose.models.PRAutomation;
await PRAutomation.create({ user: 'user123', conversationId });
const cleanup = jest
.spyOn(PRAutomation, 'deleteMany')
.mockRejectedValue(new Error('cleanup unavailable'));
try {
await expect(deleteConvos('user123', { conversationId })).resolves.toMatchObject({
deletedCount: 1,
});
expect(cleanup.mock.calls.length).toBeGreaterThan(1);
} finally {
cleanup.mockRestore();
}
});

it('removes the PR automation record of a root that a previous attempt already deleted', async () => {
const rootId = uuidv4();
const childId = uuidv4();
const PRAutomation = mongoose.models.PRAutomation;
await Conversation.create({
conversationId: childId,
user: 'user123',
endpoint: EModelEndpoint.agents,
subagentThread: {
rootConversationId: rootId,
parentConversationId: rootId,
parentMessageId: 'message-1',
parentToolCallId: 'call-1',
subagentType: 'agent-child',
subagentKind: 'agent',
depth: 1,
},
});
await PRAutomation.create({ user: 'user123', conversationId: rootId });

await deleteConvos('user123', { conversationId: rootId }, { allowEmpty: true });

expect(await PRAutomation.countDocuments({ conversationId: rootId })).toBe(0);
});

it('retires queued-turn work before each conversation deletion wave', async () => {
const conversationId = uuidv4();
await Conversation.create({
Expand Down
14 changes: 14 additions & 0 deletions packages/data-schemas/src/methods/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { isAgentFadingTier, isAgentFadingTierEntries } from '~/utils/fading';
import { isCompactionSemanticIndexProjection } from '~/types/compaction';
import { withoutMeiliIndexing } from '~/models/plugins/mongoMeili';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import { createPRAutomationMethods } from './prAutomation';
import { isValidObjectIdString } from '~/utils/objectId';
import { decrementTagCounts } from './conversationTag';
import logger from '~/config/winston';
Expand Down Expand Up @@ -669,6 +670,7 @@ export function createConversationMethods(
deps?: ConversationMethodDeps,
): ConversationMethods {
let legacyReceiptExpiryCursor: Types.ObjectId | undefined;
const prAutomation = createPRAutomationMethods(mongoose);

/**
* Stamps a real assistant reply with a strictly increasing server value.
Expand Down Expand Up @@ -4123,6 +4125,13 @@ export function createConversationMethods(
await options?.beforeDelete?.(waveIds);
await deps?.prepareAgentTriggerConversationResultErasure?.(user, waveIds);
const result = await Conversation.deleteMany({ user, conversationId: { $in: waveIds } });
/** Only after the delete committed, so a failed delete keeps the user's automation.
* A claim refuses any record whose conversation is gone and removes it, so a cleanup
* that still fails after its retry converges on the next claim. */
await retryCascadeOperation(() => prAutomation.deletePRAutomations(user, waveIds)).catch(
Comment thread
berry-13 marked this conversation as resolved.
(error) =>
logger.warn('[deleteConvos] PR automation cleanup deferred to its next claim', error),
);
if (result.deletedCount > 0) {
/** Result erasure is irreversible. Keep receipts intact when a
* pre-delete hook or the conversation delete itself fails, so a
Expand Down Expand Up @@ -4156,6 +4165,11 @@ export function createConversationMethods(
];

if (recoveryConversationIds.length > 0) {
await retryCascadeOperation(() =>
prAutomation.deletePRAutomations(user, recoveryConversationIds),
).catch((error) =>
logger.warn('[deleteConvos] PR automation cleanup deferred to its next claim', error),
);
await deps?.deleteAgentQueuedTurns?.(
user,
recoveryConversationIds.map((conversationId) => ({
Expand Down
5 changes: 5 additions & 0 deletions packages/data-schemas/src/methods/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
MAX_TOOL_FAVORITES,
type ToolFavoriteMethods,
} from './favorite';
/* PR Automation */
import { createPRAutomationMethods, type PRAutomationMethods } from './prAutomation';
/* Agent Categories */
import { createAgentCategoryMethods, type AgentCategoryMethods } from './agentCategory';
/* Agent API Keys */
Expand Down Expand Up @@ -260,6 +262,7 @@ export type AllMethods = ToolApprovalGrantStorage &
FileMethods &
MemoryMethods &
ToolFavoriteMethods &
PRAutomationMethods &
AgentCategoryMethods &
AgentApiKeyMethods &
MCPServerMethods &
Expand Down Expand Up @@ -498,6 +501,7 @@ export function createMethods(
...createFileMethods(mongoose),
...createMemoryMethods(mongoose),
...createToolFavoriteMethods(mongoose),
...createPRAutomationMethods(mongoose),
...createAgentCategoryMethods(mongoose),
...createAgentApiKeyMethods(mongoose),
...createMCPServerMethods(mongoose),
Expand Down Expand Up @@ -569,6 +573,7 @@ export type {
ProjectFileRecord,
ProjectFilesOptions,
ToolFavoriteMethods,
PRAutomationMethods,
AgentCategoryMethods,
AgentApiKeyMethods,
MCPServerMethods,
Expand Down
Loading
Loading