From 0a00b61ee327b29c81d59334d2a41defe27da5d5 Mon Sep 17 00:00:00 2001 From: enjoyandlove Date: Fri, 5 Jun 2026 11:45:29 -0400 Subject: [PATCH 1/4] feat(control-panel): add authenticated maintainer settings update endpoint --- .../public/downloads/gittensory-extension.zip | Bin 0 -> 25651 bytes apps/gittensory-ui/public/openapi.json | 30 +++ src/api/routes.ts | 46 +++++ src/openapi/spec.ts | 9 + test/integration/maintainer-settings.test.ts | 184 ++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 apps/gittensory-ui/public/downloads/gittensory-extension.zip create mode 100644 test/integration/maintainer-settings.test.ts diff --git a/apps/gittensory-ui/public/downloads/gittensory-extension.zip b/apps/gittensory-ui/public/downloads/gittensory-extension.zip new file mode 100644 index 0000000000000000000000000000000000000000..135697992d04fe23e75f30cb7c6549ed9a92b281 GIT binary patch literal 25651 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 P{`==2+`9EIC-n6H$GcLn literal 0 HcmV?d00001 diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 73dde933..cb165d04 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -13544,6 +13544,36 @@ } ] } + }, + "/v1/app/repos/{owner}/{repo}/settings": { + "post": { + "responses": { + "200": { + "description": "Updated repository automation settings (requires maintainer, owner, or operator role with repo access)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySettings" + } + } + } + }, + "400": { + "description": "Invalid repository settings" + }, + "403": { + "description": "Insufficient role or repo access" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index 94bd6d82..ab27fd3e 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1416,6 +1416,52 @@ export function createApp() { return c.json(await getRepositorySettings(c.env, fullName)); }); + app.post("/v1/app/repos/:owner/:repo/settings", async (c) => { + const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]); + if (forbidden) return forbidden; + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const identity = await authenticateRequestIdentity(c); + const repo = await getRepository(c.env, fullName); + if (identity?.kind === "session") { + const repoForbidden = await requireSessionRepoAccess(c, identity, fullName, repo); + if (repoForbidden) return repoForbidden; + } + const body = (await c.req.json().catch(() => null)) ?? {}; + const parsed = repositorySettingsSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_repository_settings", issues: parsed.error.issues }, 400); + const updated = await upsertRepositorySettings(c.env, { + repoFullName: fullName, + commentMode: parsed.data.commentMode, + publicSignalLevel: parsed.data.publicSignalLevel, + checkRunMode: parsed.data.checkRunMode, + checkRunDetailLevel: parsed.data.checkRunDetailLevel, + autoLabelEnabled: parsed.data.autoLabelEnabled, + gittensorLabel: parsed.data.gittensorLabel, + createMissingLabel: parsed.data.createMissingLabel, + publicSurface: parsed.data.publicSurface, + includeMaintainerAuthors: parsed.data.includeMaintainerAuthors, + requireLinkedIssue: parsed.data.requireLinkedIssue, + backfillEnabled: parsed.data.backfillEnabled, + privateTrustEnabled: parsed.data.privateTrustEnabled, + }); + await recordAuditEvent(c.env, { + eventType: "settings.updated", + actor: identity?.actor ?? null, + route: c.req.path, + targetKey: fullName, + outcome: "success", + detail: `Maintainer updated settings for ${fullName}`, + metadata: { + publicSurface: parsed.data.publicSurface, + checkRunMode: parsed.data.checkRunMode, + autoLabelEnabled: parsed.data.autoLabelEnabled, + includeMaintainerAuthors: parsed.data.includeMaintainerAuthors, + requireLinkedIssue: parsed.data.requireLinkedIssue, + }, + }).catch(() => undefined); + return c.json(updated); + }); + app.post("/v1/repos/:owner/:repo/settings-preview", async (c) => { const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; const body = (await c.req.json().catch(() => null)) ?? {}; diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 19b89006..a8a6f464 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -378,6 +378,15 @@ export function buildOpenApiSpec() { 200: { description: "Gittensory repository automation settings", content: { "application/json": { schema: RepositorySettingsSchema } } }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/app/repos/{owner}/{repo}/settings", + responses: { + 200: { description: "Updated repository automation settings (requires maintainer, owner, or operator role with repo access)", content: { "application/json": { schema: RepositorySettingsSchema } } }, + 400: { description: "Invalid repository settings" }, + 403: { description: "Insufficient role or repo access" }, + }, + }); registry.registerPath({ method: "post", path: "/v1/repos/{owner}/{repo}/settings-preview", diff --git a/test/integration/maintainer-settings.test.ts b/test/integration/maintainer-settings.test.ts new file mode 100644 index 00000000..40deaf5c --- /dev/null +++ b/test/integration/maintainer-settings.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +const VALID_SETTINGS = { + commentMode: "detected_contributors_only", + publicSignalLevel: "standard", + checkRunMode: "off", + checkRunDetailLevel: "standard", + autoLabelEnabled: true, + gittensorLabel: "gittensor", + createMissingLabel: true, + publicSurface: "comment_and_label", + includeMaintainerAuthors: false, + requireLinkedIssue: false, + backfillEnabled: true, + privateTrustEnabled: true, +}; + +function apiHeaders(env: Env): Record { + return { authorization: `Bearer ${env.GITTENSORY_API_TOKEN}`, "content-type": "application/json" }; +} + +async function setupMaintainerFixture(env: Env, maintainerLogin: string, repoFullName: string) { + const slashIdx = repoFullName.indexOf("/"); + const owner = repoFullName.slice(0, slashIdx); + const name = repoFullName.slice(slashIdx + 1); + await upsertInstallation(env, { + installation: { + id: 55, + account: { login: owner, id: 10, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["pull_request"], + }, + }); + await upsertRepositoryFromGitHub(env, { name, full_name: repoFullName, private: false, owner: { login: owner }, default_branch: "main" }, 55); + await upsertPullRequestFromGitHub(env, repoFullName, { number: 1, title: "Fix bug", state: "open", user: { login: maintainerLogin }, body: null, labels: [], draft: false, author_association: "MEMBER" }); +} + +describe("maintainer settings update authorization", () => { + it("allows an operator (static API token) to update repo settings and records an audit event", async () => { + const app = createApp(); + const env = createTestEnv(); + + const response = await app.request( + "/v1/app/repos/owner/project/settings", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify(VALID_SETTINGS) }, + env, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.publicSurface).toBe("comment_and_label"); + expect(body.gittensorLabel).toBe("gittensor"); + + const auditRow = (await env.DB.prepare("SELECT event_type, actor, target_key, outcome FROM audit_events WHERE event_type = ?") + .bind("settings.updated") + .first<{ event_type: string; actor: string | null; target_key: string | null; outcome: string }>()); + expect(auditRow).toMatchObject({ event_type: "settings.updated", target_key: "owner/project", outcome: "success" }); + }); + + it("allows a maintainer session with PR-association evidence to update their own repo settings", async () => { + const app = createApp(); + const env = createTestEnv(); + await setupMaintainerFixture(env, "alice", "owner/project"); + const { token } = await createSessionForGitHubUser(env, { login: "alice", id: 42 }); + const sessionHeaders = { authorization: `Bearer ${token}`, "content-type": "application/json" }; + + const response = await app.request( + "/v1/app/repos/owner/project/settings", + { method: "POST", headers: sessionHeaders, body: JSON.stringify({ ...VALID_SETTINGS, publicSurface: "comment_only" }) }, + env, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.publicSurface).toBe("comment_only"); + }); + + it("rejects a non-maintainer session with insufficient_role", async () => { + const app = createApp(); + const env = createTestEnv(); + const { token } = await createSessionForGitHubUser(env, { login: "outsider", id: 99 }); + const sessionHeaders = { authorization: `Bearer ${token}`, "content-type": "application/json" }; + + const response = await app.request( + "/v1/app/repos/owner/project/settings", + { method: "POST", headers: sessionHeaders, body: JSON.stringify(VALID_SETTINGS) }, + env, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "insufficient_role" }); + }); + + it("rejects a maintainer session that tries to update a repo outside their scope", async () => { + const app = createApp(); + const env = createTestEnv(); + await setupMaintainerFixture(env, "alice", "alice-org/alice-repo"); + const { token } = await createSessionForGitHubUser(env, { login: "alice", id: 42 }); + const sessionHeaders = { authorization: `Bearer ${token}`, "content-type": "application/json" }; + + const response = await app.request( + "/v1/app/repos/victim-org/secret-repo/settings", + { method: "POST", headers: sessionHeaders, body: JSON.stringify(VALID_SETTINGS) }, + env, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "forbidden_repo" }); + }); + + it("rejects unauthenticated requests with 401", async () => { + const app = createApp(); + const env = createTestEnv(); + + const response = await app.request( + "/v1/app/repos/owner/project/settings", + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(VALID_SETTINGS) }, + env, + ); + + expect(response.status).toBe(401); + }); + + it("rejects invalid settings body with 400", async () => { + const app = createApp(); + const env = createTestEnv(); + + const response = await app.request( + "/v1/app/repos/owner/project/settings", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ publicSurface: "not_a_valid_enum" }) }, + env, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_repository_settings" }); + }); + + it("response never contains private scoring or wallet language", async () => { + const app = createApp(); + const env = createTestEnv(); + + const response = await app.request( + "/v1/app/repos/owner/project/settings", + { method: "POST", headers: apiHeaders(env), body: JSON.stringify(VALID_SETTINGS) }, + env, + ); + + expect(response.status).toBe(200); + const raw = JSON.stringify(await response.json()); + expect(raw).not.toMatch(/wallet|hotkey|raw trust|reward estimate|payout|farming|private reviewability|scoreability|public score estimate/i); + }); + + it("allows an owner-installation session to update their own repo settings", async () => { + const app = createApp(); + const env = createTestEnv(); + await upsertInstallation(env, { + installation: { + id: 77, + account: { login: "repo-owner", id: 20, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["pull_request"], + }, + }); + await upsertRepositoryFromGitHub(env, { name: "owned-repo", full_name: "repo-owner/owned-repo", private: false, owner: { login: "repo-owner" }, default_branch: "main" }, 77); + const { token } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 20 }); + const sessionHeaders = { authorization: `Bearer ${token}`, "content-type": "application/json" }; + + const response = await app.request( + "/v1/app/repos/repo-owner/owned-repo/settings", + { method: "POST", headers: sessionHeaders, body: JSON.stringify({ ...VALID_SETTINGS, requireLinkedIssue: true }) }, + env, + ); + + const body = (await response.json()) as Record; + expect(response.status).toBe(200); + expect(body.requireLinkedIssue).toBe(true); + }); +}); From dad98c11e8dce0c8946cd7c5d6cead7ea329fc83 Mon Sep 17 00:00:00 2001 From: enjoyandlove Date: Fri, 5 Jun 2026 14:29:19 -0400 Subject: [PATCH 2/4] chore(ui): regenerate openapi.json with CI env var --- apps/gittensory-ui/public/openapi.json | 332 +++++++++++++------------ 1 file changed, 172 insertions(+), 160 deletions(-) diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 472641dc..97d0e104 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -3385,6 +3385,13 @@ "all_prs" ] }, + "publicAudienceMode": { + "type": "string", + "enum": [ + "oss_maintainer", + "gittensor_only" + ] + }, "checkRunMode": { "type": "string", "enum": [ @@ -3392,6 +3399,13 @@ "enabled" ] }, + "gateCheckMode": { + "type": "string", + "enum": [ + "off", + "enabled" + ] + }, "quietByDefault": { "type": "boolean" }, @@ -3403,20 +3417,6 @@ "items": { "type": "string" } - }, - "publicAudienceMode": { - "type": "string", - "enum": [ - "oss_maintainer", - "gittensor_only" - ] - }, - "gateCheckMode": { - "type": "string", - "enum": [ - "off", - "enabled" - ] } }, "required": [ @@ -7384,40 +7384,6 @@ "high" ] }, - "linkedPrs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "number": { - "type": "number" - }, - "state": { - "type": "string", - "enum": [ - "open", - "closed", - "merged", - "unknown" - ] - }, - "isActive": { - "type": "boolean" - } - }, - "required": [ - "number", - "state", - "isActive" - ] - } - }, - "findings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Finding" - } - }, "source": { "type": "object", "properties": { @@ -7454,6 +7420,40 @@ "ageDays", "freshness" ] + }, + "linkedPrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "number" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged", + "unknown" + ] + }, + "isActive": { + "type": "boolean" + } + }, + "required": [ + "number", + "state", + "isActive" + ] + } + }, + "findings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Finding" + } } }, "required": [ @@ -7537,6 +7537,13 @@ "all_prs" ] }, + "publicAudienceMode": { + "type": "string", + "enum": [ + "oss_maintainer", + "gittensor_only" + ] + }, "publicSignalLevel": { "type": "string", "enum": [ @@ -7559,6 +7566,13 @@ "deep" ] }, + "gateCheckMode": { + "type": "string", + "enum": [ + "off", + "enabled" + ] + }, "autoLabelEnabled": { "type": "boolean" }, @@ -7632,20 +7646,6 @@ "updatedAt": { "type": "string", "nullable": true - }, - "publicAudienceMode": { - "type": "string", - "enum": [ - "oss_maintainer", - "gittensor_only" - ] - }, - "gateCheckMode": { - "type": "string", - "enum": [ - "off", - "enabled" - ] } }, "required": [ @@ -7707,21 +7707,18 @@ "all_prs" ] }, - "checkRunMode": { + "publicAudienceMode": { "type": "string", "enum": [ - "off", - "enabled" + "oss_maintainer", + "gittensor_only" ] }, - "autoLabelEnabled": { - "type": "boolean" - }, - "publicAudienceMode": { + "checkRunMode": { "type": "string", "enum": [ - "oss_maintainer", - "gittensor_only" + "off", + "enabled" ] }, "gateCheckMode": { @@ -7730,6 +7727,9 @@ "off", "enabled" ] + }, + "autoLabelEnabled": { + "type": "boolean" } }, "required": [ @@ -8089,6 +8089,13 @@ "all_prs" ] }, + "publicAudienceMode": { + "type": "string", + "enum": [ + "oss_maintainer", + "gittensor_only" + ] + }, "publicSignalLevel": { "type": "string", "enum": [ @@ -8111,6 +8118,13 @@ "deep" ] }, + "gateCheckMode": { + "type": "string", + "enum": [ + "off", + "enabled" + ] + }, "autoLabelEnabled": { "type": "boolean" }, @@ -8173,20 +8187,6 @@ "defaultAllowed", "commandOverrides" ] - }, - "publicAudienceMode": { - "type": "string", - "enum": [ - "oss_maintainer", - "gittensor_only" - ] - }, - "gateCheckMode": { - "type": "string", - "enum": [ - "off", - "enabled" - ] } }, "required": [ @@ -11078,30 +11078,6 @@ "warnings" ] }, - "status": { - "type": "string", - "enum": [ - "ready", - "needs_proof", - "hold", - "do_not_use" - ] - }, - "score": { - "type": "number" - }, - "reasons": { - "type": "array", - "items": { - "type": "string" - } - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - } - }, "bounty": { "type": "object", "properties": { @@ -11214,6 +11190,30 @@ "source", "linkedPrs" ] + }, + "status": { + "type": "string", + "enum": [ + "ready", + "needs_proof", + "hold", + "do_not_use" + ] + }, + "score": { + "type": "number" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -12332,6 +12332,39 @@ ] } }, + "/v1/repos/{owner}/{repo}/onboarding-pack/preview": { + "get": { + "responses": { + "200": { + "description": "Preview-only repo onboarding pack for accepted repositories", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "403": { + "description": "Insufficient role" + }, + "404": { + "description": "Repository is not accepted or preview unavailable" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/repos/{owner}/{repo}/settings": { "get": { "responses": { @@ -12356,6 +12389,36 @@ ] } }, + "/v1/app/repos/{owner}/{repo}/settings": { + "post": { + "responses": { + "200": { + "description": "Updated repository automation settings (requires maintainer, owner, or operator role with repo access)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositorySettings" + } + } + } + }, + "400": { + "description": "Invalid repository settings" + }, + "403": { + "description": "Insufficient role or repo access" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/repos/{owner}/{repo}/settings-preview": { "post": { "responses": { @@ -14092,57 +14155,6 @@ } ] } - }, - "/v1/app/repos/{owner}/{repo}/settings": { - "post": { - "responses": { - "200": { - "description": "Updated repository automation settings (requires maintainer, owner, or operator role with repo access)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositorySettings" - } - } - } - }, - "400": { - "description": "Invalid repository settings" - }, - "403": { - "description": "Insufficient role or repo access" - "/v1/repos/{owner}/{repo}/onboarding-pack/preview": { - "get": { - "responses": { - "200": { - "description": "Preview-only repo onboarding pack for accepted repositories", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "403": { - "description": "Insufficient role" - }, - "404": { - "description": "Repository is not accepted or preview unavailable" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } } }, "servers": [ From 9a6eb395e012f67dbca288c265860fc40a5c1911 Mon Sep 17 00:00:00 2001 From: enjoyandlove Date: Fri, 5 Jun 2026 16:40:25 -0400 Subject: [PATCH 3/4] test(mcp): cover monitor-open-prs, computed source, elicitation catch, and queue-level low branch --- src/api/routes.ts | 1 + test/unit/mcp-tool-branches.test.ts | 86 +++++++++++++++++++++++++++++ test/unit/routes-extension.test.ts | 24 ++++++++ 3 files changed, 111 insertions(+) create mode 100644 test/unit/mcp-tool-branches.test.ts diff --git a/src/api/routes.ts b/src/api/routes.ts index 73a3b377..ea32303e 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -3632,4 +3632,5 @@ export const __routesInternals = { buildExtensionPrivateBlockers, ensureExtensionPublicSafeText, authenticateRequestIdentity, + extensionQueueLevel, }; diff --git a/test/unit/mcp-tool-branches.test.ts b/test/unit/mcp-tool-branches.test.ts new file mode 100644 index 00000000..94c76f2c --- /dev/null +++ b/test/unit/mcp-tool-branches.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { ElicitRequestSchema, type ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +async function connectTestClient(capabilities: ClientCapabilities, env = createTestEnv()) { + const mcpServer = new GittensoryMcp(env).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mcpServer.connect(serverTransport); + const client = new Client({ name: "gittensory-branch-test", version: "0.1.0" }, { capabilities }); + await client.connect(clientTransport); + return { client, mcpServer }; +} + +describe("gittensory_monitor_open_prs", () => { + it("returns open PR monitor summary for a known login", async () => { + const { client, mcpServer } = await connectTestClient({}); + const result = await client.callTool({ name: "gittensory_monitor_open_prs", arguments: { login: "oktofeesh1" } }); + expect(result.isError, JSON.stringify(result.content)).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data).toMatchObject({ login: "oktofeesh1", summary: expect.any(String) }); + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|coldkey|reward estimate|payout|farming/i); + await mcpServer.close(); + }); +}); + +describe("gittensory_get_issue_quality computed source", () => { + it("returns computed source when no snapshot exists for a known repo", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { + name: "mcp-branch-issue-quality", + full_name: "entrius/mcp-branch-issue-quality", + private: false, + default_branch: "main", + owner: { login: "entrius" }, + }); + const { client, mcpServer } = await connectTestClient({}, env); + const result = await client.callTool({ + name: "gittensory_get_issue_quality", + arguments: { owner: "entrius", repo: "mcp-branch-issue-quality" }, + }); + expect(result.isError, JSON.stringify(result.content)).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data).toMatchObject({ status: "ready", source: "computed", repoFullName: "entrius/mcp-branch-issue-quality" }); + await mcpServer.close(); + }); +}); + +describe("gittensory_get_repo_outcome_patterns computed source", () => { + it("returns computed source when no snapshot exists for a known repo", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { + name: "mcp-branch-outcome-patterns", + full_name: "entrius/mcp-branch-outcome-patterns", + private: false, + default_branch: "main", + owner: { login: "entrius" }, + }); + const { client, mcpServer } = await connectTestClient({}, env); + const result = await client.callTool({ + name: "gittensory_get_repo_outcome_patterns", + arguments: { owner: "entrius", repo: "mcp-branch-outcome-patterns" }, + }); + expect(result.isError, JSON.stringify(result.content)).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data).toMatchObject({ status: "ready", source: "computed", repoFullName: "entrius/mcp-branch-outcome-patterns" }); + await mcpServer.close(); + }); +}); + +describe("planning elicitation sendRequest error fallback", () => { + it("returns accepted: false when sendRequest throws", async () => { + const { client, mcpServer } = await connectTestClient({ elicitation: { form: {} } }); + client.setRequestHandler(ElicitRequestSchema, async () => { + throw new Error("simulated elicitation transport failure"); + }); + const result = await client.callTool({ name: "gittensory_agent_plan_next_work", arguments: { login: "oktofeesh1" } }); + expect(result.isError, JSON.stringify(result.content)).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.planningElicitation).toMatchObject({ supported: true, requested: true, accepted: false }); + await mcpServer.close(); + }); +}); diff --git a/test/unit/routes-extension.test.ts b/test/unit/routes-extension.test.ts index 6ecba99f..acb4fff5 100644 --- a/test/unit/routes-extension.test.ts +++ b/test/unit/routes-extension.test.ts @@ -77,3 +77,27 @@ describe("extension packet helper internals", () => { expect(identity).toMatchObject({ kind: "session", actor: "jsonbored" }); }); }); + +describe("extensionQueueLevel", () => { + it("returns 'high' when repo open PRs >= 8", () => { + expect(__routesInternals.extensionQueueLevel(8, 0)).toBe("high"); + }); + + it("returns 'high' when author open PRs >= 4", () => { + expect(__routesInternals.extensionQueueLevel(0, 4)).toBe("high"); + }); + + it("returns 'medium' when repo open PRs >= 4 and author < 4", () => { + expect(__routesInternals.extensionQueueLevel(4, 0)).toBe("medium"); + }); + + it("returns 'medium' when author open PRs >= 2 and repo < 8", () => { + expect(__routesInternals.extensionQueueLevel(1, 2)).toBe("medium"); + }); + + it("returns 'low' when repo and author open PRs are below thresholds", () => { + expect(__routesInternals.extensionQueueLevel(1, 1)).toBe("low"); + expect(__routesInternals.extensionQueueLevel(0, 0)).toBe("low"); + expect(__routesInternals.extensionQueueLevel(3, 1)).toBe("low"); + }); +}); From c6c5544edb6ab92061230249930d80d17a60548a Mon Sep 17 00:00:00 2001 From: enjoyandlove Date: Fri, 5 Jun 2026 16:44:20 -0400 Subject: [PATCH 4/4] chore(ui): regenerate openapi.json Co-Authored-By: Claude Sonnet 4.6 --- apps/gittensory-ui/public/openapi.json | 28 -------------------------- 1 file changed, 28 deletions(-) diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 8d44c5e7..33df005e 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -7647,20 +7647,6 @@ "type": "string", "nullable": true }, - "publicAudienceMode": { - "type": "string", - "enum": [ - "oss_maintainer", - "gittensor_only" - ] - }, - "gateCheckMode": { - "type": "string", - "enum": [ - "off", - "enabled" - ] - }, "linkedIssueGateMode": { "type": "string", "enum": [ @@ -8233,20 +8219,6 @@ "commandOverrides" ] }, - "publicAudienceMode": { - "type": "string", - "enum": [ - "oss_maintainer", - "gittensor_only" - ] - }, - "gateCheckMode": { - "type": "string", - "enum": [ - "off", - "enabled" - ] - }, "linkedIssueGateMode": { "type": "string", "enum": [