diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 5393df9f21..c2dd6b9082 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3348,14 +3348,19 @@ export async function createPendingAgentActionIfAbsent( return { action: toAgentPendingActionRecord(existing), created: false }; } +function pendingAgentActionConditions(options: { repoFullName?: string; status?: AgentPendingActionStatus } = {}): SQL[] { + const conditions = []; + if (options.repoFullName) conditions.push(eq(agentPendingActions.repoFullName, options.repoFullName)); + if (options.status) conditions.push(eq(agentPendingActions.status, options.status)); + return conditions; +} + export async function listPendingAgentActions( env: Env, options: { repoFullName?: string; status?: AgentPendingActionStatus; limit?: number } = {}, ): Promise { const limit = clampInteger(options.limit ?? 200, 1, 2000); - const conditions = []; - if (options.repoFullName) conditions.push(eq(agentPendingActions.repoFullName, options.repoFullName)); - if (options.status) conditions.push(eq(agentPendingActions.status, options.status)); + const conditions = pendingAgentActionConditions(options); const rows = await getDb(env.DB) .select() .from(agentPendingActions) @@ -3365,6 +3370,18 @@ export async function listPendingAgentActions( return rows.map(toAgentPendingActionRecord); } +export async function countPendingAgentActions( + env: Env, + options: { repoFullName?: string; status?: AgentPendingActionStatus } = {}, +): Promise { + const conditions = pendingAgentActionConditions(options); + const [row] = await getDb(env.DB) + .select({ count: sql`count(*)` }) + .from(agentPendingActions) + .where(conditions.length === 0 ? undefined : and(...conditions)); + return Number(row?.count ?? 0); +} + export async function getPendingAgentAction(env: Env, id: string): Promise { const [row] = await getDb(env.DB).select().from(agentPendingActions).where(eq(agentPendingActions.id, id)).limit(1); return row ? toAgentPendingActionRecord(row) : null; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5c46243ebc..4d30535057 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -8,6 +8,7 @@ import { authenticatePrivateToken, extractBearerToken, type AuthIdentity } from import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; import { countOpenIssues, + countPendingAgentActions, countOpenPullRequests, createPendingAgentActionIfAbsent, getBounty, @@ -2050,10 +2051,10 @@ export class GittensoryMcp { private async getAutomationState(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; await this.requireRepoAccess(fullName); - const [repo, settings, pending] = await Promise.all([ + const [repo, settings, pendingActionCount] = await Promise.all([ getRepository(this.env, fullName), getRepositorySettings(this.env, fullName), - listPendingAgentActions(this.env, { repoFullName: fullName, status: "pending" }), + countPendingAgentActions(this.env, { repoFullName: fullName, status: "pending" }), ]); const autonomy = settings.autonomy; const actingActionClasses = AGENT_ACTION_CLASSES.filter((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); @@ -2061,7 +2062,7 @@ export class GittensoryMcp { const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(this.env), agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun }); const permissionReadiness = resolveAgentPermissionReadiness({ autonomy, installationPermissions: installation?.permissions ?? null }); return { - summary: `Agent automation for ${fullName}: mode=${mode}, ${actingActionClasses.length} acting class(es), ${pending.length} pending approval(s).`, + summary: `Agent automation for ${fullName}: mode=${mode}, ${actingActionClasses.length} acting class(es), ${pendingActionCount} pending approval(s).`, data: { repoFullName: fullName, configured: actingActionClasses.length > 0, @@ -2072,7 +2073,7 @@ export class GittensoryMcp { mode, permissionReadiness, actingActionClasses, - pendingActionCount: pending.length, + pendingActionCount, }, }; } diff --git a/test/unit/agent-approval-queue.test.ts b/test/unit/agent-approval-queue.test.ts index e988f53e92..01e2bef1ad 100644 --- a/test/unit/agent-approval-queue.test.ts +++ b/test/unit/agent-approval-queue.test.ts @@ -15,10 +15,12 @@ import { ensurePullRequestLabel } from "../../src/github/labels"; import { actionParams, executeAgentMaintenanceActions, pendingActionToPlanned, type AgentActionExecutionContext } from "../../src/services/agent-action-executor"; import { decidePendingAgentAction } from "../../src/services/agent-approval-queue"; import { + countPendingAgentActions, createPendingAgentActionIfAbsent, getPendingAgentAction, listNotificationDeliveriesForRecipient, listPendingAgentActions, + setPendingAgentActionStatus, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositorySettings, @@ -190,4 +192,39 @@ describe("agent approval queue (#779)", () => { expect(pendingActionToPlanned({ actionClass: "merge", params: { mergeMethod: "squash" } })).toMatchObject({ actionClass: "merge", requiresApproval: false, reason: "maintainer-approved", mergeMethod: "squash" }); expect(pendingActionToPlanned({ actionClass: "label", params: { label: "L" }, reason: "explicit" }).reason).toBe("explicit"); }); + + it("countPendingAgentActions respects both the repo filter and the status filter", async () => { + const env = createTestEnv({}); + // owner/repo: 3 pending rows (PRs 1-3) + 1 that we decide as rejected (PR 4). + for (let pullNumber = 1; pullNumber <= 4; pullNumber += 1) { + await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + } + const { action: rejected } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 5, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + await setPendingAgentActionStatus(env, rejected.id, { status: "rejected", decidedBy: "owner" }); + // other/repo: 2 pending rows (PRs 1-2) — must be excluded by the repo filter. + for (let pullNumber = 1; pullNumber <= 2; pullNumber += 1) { + await createPendingAgentActionIfAbsent(env, { repoFullName: "other/repo", pullNumber, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + } + + // No filter: counts every row across both repos and all statuses (4 + 1 rejected + 2 = 7). + expect(await countPendingAgentActions(env, {})).toBe(7); + // Repo filter only: every owner/repo row regardless of status (4 pending + 1 rejected). + expect(await countPendingAgentActions(env, { repoFullName: "owner/repo" })).toBe(5); + // Status filter only: every pending row across both repos (4 + 2). + expect(await countPendingAgentActions(env, { status: "pending" })).toBe(6); + // Both filters: only owner/repo's pending rows, excluding the rejected one and other/repo. + expect(await countPendingAgentActions(env, { repoFullName: "owner/repo", status: "pending" })).toBe(4); + // Sanity: a repo with no rows counts zero. + expect(await countPendingAgentActions(env, { repoFullName: "nobody/repo", status: "pending" })).toBe(0); + }); + + it("countPendingAgentActions counts the full set beyond the 200-row list page size", async () => { + const env = createTestEnv({}); + for (let pullNumber = 1; pullNumber <= 201; pullNumber += 1) { + await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + } + // listPendingAgentActions caps at 200 by default; the count query is not page-limited. + expect(await listPendingAgentActions(env, { repoFullName: "owner/repo", status: "pending" })).toHaveLength(200); + expect(await countPendingAgentActions(env, { repoFullName: "owner/repo", status: "pending" })).toBe(201); + }); }); diff --git a/test/unit/mcp-automation-state.test.ts b/test/unit/mcp-automation-state.test.ts index e039e342c4..341de48311 100644 --- a/test/unit/mcp-automation-state.test.ts +++ b/test/unit/mcp-automation-state.test.ts @@ -62,6 +62,22 @@ describe("MCP gittensory_get_automation_state (#784)", () => { expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i); }); + it("reports the total pending-approval count beyond the list page size", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval" } }); + for (let pullNumber = 1; pullNumber <= 201; pullNumber += 1) { + await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); + } + + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_get_automation_state", arguments: { owner: "owner", repo: "repo" } }); + + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as State; + expect(data.pendingActionCount).toBe(201); + }); + it("reports unconfigured + not_required readiness for an unknown / un-onboarded repo (no repo record)", async () => { const env = createTestEnv(); // no repo seeded → getRepository returns null (exercises the no-installation path) + default settings.