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 00000000..13569799 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 5b2fc50a..33df005e 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" }, @@ -7633,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": [ @@ -7738,21 +7738,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": { @@ -7761,6 +7758,9 @@ "off", "enabled" ] + }, + "autoLabelEnabled": { + "type": "boolean" } }, "required": [ @@ -8120,6 +8120,13 @@ "all_prs" ] }, + "publicAudienceMode": { + "type": "string", + "enum": [ + "oss_maintainer", + "gittensor_only" + ] + }, "publicSignalLevel": { "type": "string", "enum": [ @@ -8142,6 +8149,13 @@ "deep" ] }, + "gateCheckMode": { + "type": "string", + "enum": [ + "off", + "enabled" + ] + }, "autoLabelEnabled": { "type": "boolean" }, @@ -8205,20 +8219,6 @@ "commandOverrides" ] }, - "publicAudienceMode": { - "type": "string", - "enum": [ - "oss_maintainer", - "gittensor_only" - ] - }, - "gateCheckMode": { - "type": "string", - "enum": [ - "off", - "enabled" - ] - }, "linkedIssueGateMode": { "type": "string", "enum": [ @@ -11140,30 +11140,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": { @@ -11276,6 +11252,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": [ @@ -12394,6 +12394,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": { @@ -12418,6 +12451,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": { @@ -14154,39 +14217,6 @@ } ] } - }, - "/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": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index 0d2458d3..3a405476 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1515,6 +1515,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)) ?? {}; @@ -3592,4 +3638,5 @@ export const __routesInternals = { buildExtensionPrivateBlockers, ensureExtensionPublicSafeText, authenticateRequestIdentity, + extensionQueueLevel, }; diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index b173894d..1f988cbc 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -389,6 +389,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); + }); +}); 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"); + }); +});