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
23 changes: 20 additions & 3 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, gte, inArray, not, or, sql, type SQL } from "drizzle-orm";

Check notice on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.

Check notice on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.
import { getDb } from "./client";
import {
advisories,
Expand Down Expand Up @@ -3348,14 +3348,19 @@
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<AgentPendingActionRecord[]> {
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)
Expand All @@ -3365,6 +3370,18 @@
return rows.map(toAgentPendingActionRecord);
}

export async function countPendingAgentActions(
env: Env,
options: { repoFullName?: string; status?: AgentPendingActionStatus } = {},
): Promise<number> {
const conditions = pendingAgentActionConditions(options);
const [row] = await getDb(env.DB)
.select({ count: sql<number>`count(*)` })
.from(agentPendingActions)
.where(conditions.length === 0 ? undefined : and(...conditions));
return Number(row?.count ?? 0);
}

export async function getPendingAgentAction(env: Env, id: string): Promise<AgentPendingActionRecord | null> {
const [row] = await getDb(env.DB).select().from(agentPendingActions).where(eq(agentPendingActions.id, id)).limit(1);
return row ? toAgentPendingActionRecord(row) : null;
Expand Down
9 changes: 5 additions & 4 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createMcpHandler } from "agents/mcp";

Check notice on line 1 in src/mcp/server.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/mcp/server.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.

Check notice on line 1 in src/mcp/server.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.
import type { Context } from "hono";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
Expand All @@ -8,6 +8,7 @@
import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles";
import {
countOpenIssues,
countPendingAgentActions,
countOpenPullRequests,
createPendingAgentActionIfAbsent,
getBounty,
Expand Down Expand Up @@ -2050,18 +2051,18 @@
private async getAutomationState(input: { owner: string; repo: string }): Promise<ToolPayload> {
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)));
const installation = repo?.installationId ? await getInstallation(this.env, repo.installationId) : null;
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,
Expand All @@ -2072,7 +2073,7 @@
mode,
permissionReadiness,
actingActionClasses,
pendingActionCount: pending.length,
pendingActionCount,
},
};
}
Expand Down
37 changes: 37 additions & 0 deletions test/unit/agent-approval-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
16 changes: 16 additions & 0 deletions test/unit/mcp-automation-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

Check notice on line 1 in test/unit/mcp-automation-state.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in test/unit/mcp-automation-state.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.

Check notice on line 1 in test/unit/mcp-automation-state.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GittensoryMcp } from "../../src/mcp/server";
Expand Down Expand Up @@ -62,6 +62,22 @@
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.
Expand Down
Loading