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 0000000000..135697992d Binary files /dev/null and b/apps/gittensory-ui/public/downloads/gittensory-extension.zip differ diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 05b6a33d8e..05acf144be 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -12817,6 +12817,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": { diff --git a/scripts/check-mcp-release-due.mjs b/scripts/check-mcp-release-due.mjs index 9f848a07d4..e05b84fa69 100644 --- a/scripts/check-mcp-release-due.mjs +++ b/scripts/check-mcp-release-due.mjs @@ -83,6 +83,11 @@ async function upsertIssue(report) { if (!token) throw new Error("GITHUB_TOKEN is required for --upsert-issue"); const [owner, repo] = repository.split("/"); if (!owner || !repo) throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`); + const issuesEnabled = await checkIssuesEnabled({ owner, repo, token }); + if (!issuesEnabled) { + process.stderr.write("Issues are disabled in this repository — skipping issue upsert.\n"); + return; + } const issue = buildMcpReleaseIssue(report); const existingIssue = await findExistingIssue({ owner, repo, token }); @@ -133,6 +138,20 @@ export function isReleaseWatchIssue(issue) { ); } +async function checkIssuesEnabled({ owner, repo, token }) { + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "gittensory-mcp-release-watch", + "x-github-api-version": "2022-11-28", + }, + }); + if (!response.ok) return false; + const payload = await response.json(); + return payload.has_issues === true; +} + async function githubRequest({ token, method, path, body }) { const response = await fetch(`https://api.github.com${path}`, { method, diff --git a/src/api/routes.ts b/src/api/routes.ts index a8732a98f6..72ebb90ef2 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1676,6 +1676,60 @@ 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); + if (body === null) return c.json({ error: "invalid_request_body" }, 400); + 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, + publicAudienceMode: parsed.data.publicAudienceMode, + publicSignalLevel: parsed.data.publicSignalLevel, + checkRunMode: parsed.data.checkRunMode, + checkRunDetailLevel: parsed.data.checkRunDetailLevel, + gateCheckMode: parsed.data.gateCheckMode, + linkedIssueGateMode: parsed.data.linkedIssueGateMode, + duplicatePrGateMode: parsed.data.duplicatePrGateMode, + qualityGateMode: parsed.data.qualityGateMode, + qualityGateMinScore: parsed.data.qualityGateMinScore, + 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, + commandAuthorization: normalizeCommandAuthorizationPolicy(parsed.data.commandAuthorization).policy, + }); + 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, + }, + }); + return c.json(updated); + }); + app.post("/v1/repos/:owner/:repo/settings-preview", async (c) => { const identity = await authenticateRequestIdentity(c); const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; @@ -3839,4 +3893,5 @@ export const __routesInternals = { buildExtensionPrivateBlockers, ensureExtensionPublicSafeText, authenticateRequestIdentity, + extensionQueueLevel, }; diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index be69bb505b..da9c168026 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -443,6 +443,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 0000000000..2d413daa2c --- /dev/null +++ b/test/integration/maintainer-settings.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it, vi } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import * as repositories from "../../src/db/repositories"; +import { upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +const VALID_SETTINGS = { + commentMode: "detected_contributors_only", + publicAudienceMode: "oss_maintainer", + publicSignalLevel: "standard", + checkRunMode: "off", + checkRunDetailLevel: "standard", + gateCheckMode: "off", + linkedIssueGateMode: "advisory", + duplicatePrGateMode: "advisory", + qualityGateMode: "advisory", + 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("returns 400 for malformed JSON request body instead of silently writing defaults", async () => { + const app = createApp(); + const env = createTestEnv(); + + const response = await app.request( + "/v1/app/repos/owner/project/settings", + { method: "POST", headers: { ...apiHeaders(env), "content-type": "application/json" }, body: "{ not valid json" }, + env, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_request_body" }); + }); + + it("propagates audit write failures instead of silently swallowing them", async () => { + const app = createApp(); + const env = createTestEnv(); + vi.spyOn(repositories, "recordAuditEvent").mockRejectedValueOnce(new Error("db write failed")); + + 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(500); + vi.restoreAllMocks(); + }); + + it("persists all gate and policy settings without silently resetting to defaults", 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, + publicAudienceMode: "gittensor_only", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + duplicatePrGateMode: "block", + qualityGateMode: "block", + qualityGateMinScore: 75, + }), + }, + env, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.publicAudienceMode).toBe("gittensor_only"); + expect(body.gateCheckMode).toBe("enabled"); + expect(body.linkedIssueGateMode).toBe("block"); + expect(body.duplicatePrGateMode).toBe("block"); + expect(body.qualityGateMode).toBe("block"); + expect(body.qualityGateMinScore).toBe(75); + }); + + 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); + }); +}); diff --git a/test/unit/mcp-tool-branches.test.ts b/test/unit/mcp-tool-branches.test.ts new file mode 100644 index 0000000000..94c76f2c2b --- /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 6ecba99f53..acb4fff5f0 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"); + }); +});