diff --git a/src/raycast/maintainer-commands.ts b/src/raycast/maintainer-commands.ts new file mode 100644 index 0000000000..dc3f92ca10 --- /dev/null +++ b/src/raycast/maintainer-commands.ts @@ -0,0 +1,359 @@ +import { sanitizePublicComment } from "../github/commands"; + +export type RaycastCommandFetch = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + }, +) => Promise<{ + ok: boolean; + status: number; + statusText?: string; + json: () => Promise; +}>; + +export type RaycastRepoTarget = { + owner: string; + repo: string; + repoFullName: string; +}; + +export type RaycastApiClient = { + apiOrigin: string; + sessionToken: string; + fetchImpl: RaycastCommandFetch; +}; + +export type RaycastPublicSurfaceSummary = { + commentMode: string; + labelMode: string; + checkMode: string; + publicSurface: string; + summary: string; +}; + +export type RaycastInstallHealthSummary = { + status: "healthy" | "needs_attention" | "not_installed" | "unavailable"; + installationId: number | null; + missingPermissions: string[]; + missingEvents: string[]; + details: string[]; + nextActions: string[]; +}; + +export type RaycastMaintainerQueueCommand = { + command: "maintainer_queue"; + repo: RaycastRepoTarget; + generatedAt: string | null; + queue: { + level: string; + openPullRequests: number | null; + openIssues: number | null; + likelyReviewablePullRequests: number | null; + warnings: string[]; + }; + installHealth: RaycastInstallHealthSummary; + publicSurface: RaycastPublicSurfaceSummary; + privateView: { + localOnly: true; + sections: string[]; + }; + actions: Array<{ + id: string; + title: string; + mode: "private_view" | "preview_only"; + endpoint: string; + mutatesGitHub: false; + }>; + privacy: { + sourceUpload: false; + storesGitHubPat: false; + githubMutations: false; + publicPacketIncludesPrivateContext: false; + }; +}; + +export type RaycastPublicPreviewCommand = { + command: "public_preview"; + repo: RaycastRepoTarget; + pullNumber: number; + body: string; + decision: Record; + warnings: string[]; + privacy: { + previewOnly: true; + sourceUpload: false; + githubMutations: false; + publicPacketIncludesPrivateContext: false; + }; +}; + +const DEFAULT_PUBLIC_SURFACE = "confirmed-miner-only"; +export function parseRaycastRepoInput(input: string): RaycastRepoTarget { + const trimmed = input.trim(); + const fromUrl = trimmed.match(/^https:\/\/github\.com\/([^/\s]+)\/([^/\s?#]+)(?:[/?#].*)?$/i); + const fromPair = trimmed.match(/^([^/\s]+)\/([^/\s]+)$/); + const match = fromUrl ?? fromPair; + if (!match?.[1] || !match?.[2]) { + throw new Error("Raycast repo input must be owner/repo or a GitHub repository URL."); + } + const owner = match[1]; + const repo = match[2].replace(/\.git$/i, ""); + return { owner, repo, repoFullName: `${owner}/${repo}` }; +} + +export async function runRaycastMaintainerQueueCommand(args: { + client: RaycastApiClient; + repoInput: string; +}): Promise { + const repo = parseRaycastRepoInput(args.repoInput); + const [intelligence, settings] = await Promise.all([ + fetchRaycastJson(args.client, `/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/intelligence`), + fetchRaycastJson(args.client, `/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/settings`).catch(() => null), + ]); + const repoRecord = recordAt(intelligence, "repo"); + const installationId = numberAt(repoRecord, "installationId"); + const installHealth = installationId === null + ? notInstalledHealth() + : summarizeInstallHealth( + await fetchRaycastJson(args.client, `/v1/installations/${installationId}/health`).catch(() => null), + installationId, + ); + return { + command: "maintainer_queue", + repo, + generatedAt: stringAt(intelligence, "generatedAt"), + queue: summarizeQueue(intelligence), + installHealth, + publicSurface: summarizePublicSurface(settings), + privateView: { + localOnly: true, + sections: privateSections(intelligence), + }, + actions: [ + { + id: "view_private_queue", + title: "View private queue context in Raycast", + mode: "private_view", + endpoint: `/v1/repos/${repo.repoFullName}/intelligence`, + mutatesGitHub: false, + }, + { + id: "preview_public_output", + title: "Preview public-safe command output", + mode: "preview_only", + endpoint: "/v1/app/commands/preview", + mutatesGitHub: false, + }, + ], + privacy: { + sourceUpload: false, + storesGitHubPat: false, + githubMutations: false, + publicPacketIncludesPrivateContext: false, + }, + }; +} + +export async function runRaycastInstallHealthCommand(args: { + client: RaycastApiClient; + repoInput: string; +}): Promise<{ command: "install_health"; repo: RaycastRepoTarget; installHealth: RaycastInstallHealthSummary }> { + const repo = parseRaycastRepoInput(args.repoInput); + const intelligence = await fetchRaycastJson(args.client, `/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/intelligence`); + const installationId = numberAt(recordAt(intelligence, "repo"), "installationId"); + return { + command: "install_health", + repo, + installHealth: installationId === null + ? notInstalledHealth() + : summarizeInstallHealth( + await fetchRaycastJson(args.client, `/v1/installations/${installationId}/health`).catch(() => null), + installationId, + ), + }; +} + +export async function runRaycastPublicPreviewCommand(args: { + client: RaycastApiClient; + repoInput: string; + pullNumber: number; + command?: string; + maintainerLogin?: string; +}): Promise { + const repo = parseRaycastRepoInput(args.repoInput); + if (!Number.isInteger(args.pullNumber) || args.pullNumber <= 0) { + throw new Error("Raycast preview requires a positive pull request number."); + } + const payload = await fetchRaycastJson(args.client, "/v1/app/commands/preview", { + command: args.command ?? "@gittensory queue-summary", + repoFullName: repo.repoFullName, + pullNumber: args.pullNumber, + sample: { + commenterLogin: args.maintainerLogin ?? "maintainer", + commenterAssociation: "OWNER", + }, + }); + const preview = recordAt(payload, "preview"); + const body = sanitizePreviewBody(stringAt(preview, "body") ?? ""); + return { + command: "public_preview", + repo, + pullNumber: args.pullNumber, + body, + decision: recordAt(preview, "decision"), + warnings: arrayOfStrings(preview, "warnings"), + privacy: { + previewOnly: true, + sourceUpload: false, + githubMutations: false, + publicPacketIncludesPrivateContext: false, + }, + }; +} + +async function fetchRaycastJson(client: RaycastApiClient, path: string, body?: Record): Promise { + const url = new URL(path, client.apiOrigin); + const response = await client.fetchImpl(url.toString(), { + method: body ? "POST" : "GET", + headers: { + accept: "application/json", + authorization: `Bearer ${client.sessionToken}`, + ...(body ? { "content-type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(errorFromPayload(payload, response)); + } + return payload; +} + +function summarizeQueue(intelligence: unknown): RaycastMaintainerQueueCommand["queue"] { + const queueHealth = recordAt(intelligence, "queueHealth"); + const signals = recordAt(queueHealth, "signals"); + return { + level: stringAt(queueHealth, "level") ?? "unknown", + openPullRequests: numberAt(signals, "openPullRequests"), + openIssues: numberAt(signals, "openIssues"), + likelyReviewablePullRequests: numberAt(signals, "likelyReviewablePullRequests"), + warnings: arrayOfStrings(recordAt(intelligence, "dataQuality"), "warnings"), + }; +} + +function summarizePublicSurface(settings: unknown): RaycastPublicSurfaceSummary { + const commentMode = stringAt(settings, "commentMode") ?? "unknown"; + const labelMode = booleanAt(settings, "autoLabelEnabled") === false ? "disabled" : "configured"; + const checkMode = stringAt(settings, "checkRunMode") ?? "unknown"; + const publicSurface = stringAt(settings, "publicSurface") ?? DEFAULT_PUBLIC_SURFACE; + return { + commentMode, + labelMode, + checkMode, + publicSurface, + summary: `Comments: ${commentMode}; labels: ${labelMode}; checks: ${checkMode}; public surface: ${publicSurface}.`, + }; +} + +function summarizeInstallHealth(payload: unknown, installationId: number): RaycastInstallHealthSummary { + if (!payload || typeof payload !== "object") { + return { + status: "unavailable", + installationId, + missingPermissions: [], + missingEvents: [], + details: ["Installation health is unavailable from the current API response."], + nextActions: ["Refresh installation health, then retry the Raycast command."], + }; + } + const missingPermissions = arrayOfStrings(payload, "missingPermissions"); + const missingEvents = arrayOfStrings(payload, "missingEvents"); + const status = missingPermissions.length === 0 && missingEvents.length === 0 && stringAt(payload, "status") === "healthy" + ? "healthy" + : "needs_attention"; + return { + status, + installationId, + missingPermissions, + missingEvents, + details: [ + status === "healthy" ? "GitHub App installation is healthy." : "GitHub App installation needs attention.", + ...missingPermissions.map((permission) => `Missing GitHub App permission: ${permission}.`), + ...missingEvents.map((event) => `Missing GitHub App event subscription: ${event}.`), + ], + nextActions: [ + ...missingPermissions.map((permission) => `Grant ${permission} permission, then approve the GitHub App permission update.`), + ...missingEvents.map((event) => `Enable the ${event} webhook event, then refresh installation health.`), + ...(missingPermissions.length === 0 && missingEvents.length === 0 ? ["No installation repair action is required."] : []), + ], + }; +} + +function notInstalledHealth(): RaycastInstallHealthSummary { + return { + status: "not_installed", + installationId: null, + missingPermissions: [], + missingEvents: [], + details: ["No GitHub App installation is linked to this repository."], + nextActions: ["Install the Gittensory GitHub App for this repository before using maintainer queue automation."], + }; +} + +function privateSections(intelligence: unknown): string[] { + return [ + ...privateSectionLine(intelligence, "maintainerLane", "Maintainer lane"), + ...privateSectionLine(intelligence, "maintainerCutReadiness", "Maintainer cut readiness"), + ...privateSectionLine(intelligence, "contributorIntakeHealth", "Contributor intake health"), + ]; +} + +function privateSectionLine(source: unknown, key: string, label: string): string[] { + const value = recordAt(source, key); + if (Object.keys(value).length === 0) return []; + const status = stringAt(value, "status") ?? stringAt(value, "level") ?? "available"; + return [`${label}: ${status}`]; +} + +function sanitizePreviewBody(body: string): string { + return sanitizePublicComment(body); +} + +function errorFromPayload(payload: unknown, response: { status: number; statusText?: string }): string { + const error = stringAt(payload, "error"); + return error ?? `${response.status} ${response.statusText ?? "Raycast API request failed"}`; +} + +function recordAt(source: unknown, key: string): Record { + if (!source || typeof source !== "object") return {}; + const value = (source as Record)[key]; + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function stringAt(source: unknown, key: string): string | null { + const value = valueAt(source, key); + return typeof value === "string" ? value : null; +} + +function numberAt(source: unknown, key: string): number | null { + const value = valueAt(source, key); + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function booleanAt(source: unknown, key: string): boolean | null { + const value = valueAt(source, key); + return typeof value === "boolean" ? value : null; +} + +function arrayOfStrings(source: unknown, key: string): string[] { + const value = valueAt(source, key); + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function valueAt(source: unknown, key: string): unknown { + if (!source || typeof source !== "object") return undefined; + return (source as Record)[key]; +} diff --git a/test/unit/raycast-maintainer-commands.test.ts b/test/unit/raycast-maintainer-commands.test.ts new file mode 100644 index 0000000000..f6e0dde53a --- /dev/null +++ b/test/unit/raycast-maintainer-commands.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it, vi } from "vitest"; +import { + parseRaycastRepoInput, + runRaycastInstallHealthCommand, + runRaycastMaintainerQueueCommand, + runRaycastPublicPreviewCommand, + type RaycastApiClient, + type RaycastCommandFetch, +} from "../../src/raycast/maintainer-commands"; + +const TOKEN = `gts_${"b".repeat(64)}`; + +describe("Raycast maintainer commands", () => { + it("normalizes repo picker/input values from owner/repo and GitHub URLs", () => { + expect(parseRaycastRepoInput("JSONbored/gittensory")).toEqual({ + owner: "JSONbored", + repo: "gittensory", + repoFullName: "JSONbored/gittensory", + }); + expect(parseRaycastRepoInput("https://github.com/JSONbored/gittensory/pulls")).toEqual({ + owner: "JSONbored", + repo: "gittensory", + repoFullName: "JSONbored/gittensory", + }); + expect(parseRaycastRepoInput("JSONbored/gittensory.git")).toEqual({ + owner: "JSONbored", + repo: "gittensory", + repoFullName: "JSONbored/gittensory", + }); + expect(() => parseRaycastRepoInput("not a repo")).toThrow(/owner\/repo/i); + }); + + it("builds the maintainer queue command from mocked API intelligence, settings, and install health", async () => { + const { client, calls } = fakeRaycastClient({ + "/v1/repos/JSONbored/gittensory/intelligence": { + generatedAt: "2026-06-04T08:00:00.000Z", + repo: { fullName: "JSONbored/gittensory", installationId: 123 }, + queueHealth: { + level: "medium", + signals: { openPullRequests: 4, openIssues: 8, likelyReviewablePullRequests: 2 }, + }, + maintainerLane: { status: "ready" }, + maintainerCutReadiness: { level: "watch" }, + contributorIntakeHealth: { status: "healthy" }, + dataQuality: { warnings: ["Queue snapshot is 2h old."] }, + }, + "/v1/repos/JSONbored/gittensory/settings": { + commentMode: "confirmed_miners", + autoLabelEnabled: true, + checkRunMode: "opt_in", + publicSurface: "public_safe", + }, + "/v1/installations/123/health": { + status: "healthy", + missingPermissions: [], + missingEvents: [], + }, + }); + + const command = await runRaycastMaintainerQueueCommand({ client, repoInput: "JSONbored/gittensory" }); + + expect(command).toMatchObject({ + command: "maintainer_queue", + repo: { repoFullName: "JSONbored/gittensory" }, + queue: { + level: "medium", + openPullRequests: 4, + openIssues: 8, + likelyReviewablePullRequests: 2, + warnings: ["Queue snapshot is 2h old."], + }, + installHealth: { status: "healthy", missingPermissions: [], missingEvents: [] }, + publicSurface: { + commentMode: "confirmed_miners", + labelMode: "configured", + checkMode: "opt_in", + publicSurface: "public_safe", + }, + privacy: { + sourceUpload: false, + storesGitHubPat: false, + githubMutations: false, + publicPacketIncludesPrivateContext: false, + }, + }); + expect(command.publicSurface.summary).toContain("Comments: confirmed_miners"); + expect(command.privateView.sections).toEqual([ + "Maintainer lane: ready", + "Maintainer cut readiness: watch", + "Contributor intake health: healthy", + ]); + expect(command.actions.every((action) => action.mutatesGitHub === false)).toBe(true); + expect(calls.map((call) => call.path)).toEqual([ + "/v1/repos/JSONbored/gittensory/intelligence", + "/v1/repos/JSONbored/gittensory/settings", + "/v1/installations/123/health", + ]); + }); + + it("keeps queue command usable when settings are missing and no installation is linked", async () => { + const { client, calls } = fakeRaycastClient({ + "/v1/repos/JSONbored/gittensory/intelligence": { + repo: { fullName: "JSONbored/gittensory" }, + queueHealth: {}, + maintainerLane: { note: "available without explicit status" }, + }, + }); + + const command = await runRaycastMaintainerQueueCommand({ client, repoInput: "JSONbored/gittensory" }); + + expect(command.queue).toMatchObject({ + level: "unknown", + openPullRequests: null, + openIssues: null, + likelyReviewablePullRequests: null, + warnings: [], + }); + expect(command.installHealth).toMatchObject({ + status: "not_installed", + installationId: null, + }); + expect(command.publicSurface).toMatchObject({ + commentMode: "unknown", + labelMode: "configured", + checkMode: "unknown", + publicSurface: "confirmed-miner-only", + }); + expect(command.privateView.sections).toEqual(["Maintainer lane: available"]); + expect(calls.map((call) => call.path)).toEqual([ + "/v1/repos/JSONbored/gittensory/intelligence", + "/v1/repos/JSONbored/gittensory/settings", + ]); + }); + + it("reports disabled labels and unavailable install health without failing the queue command", async () => { + const { client } = fakeRaycastClient({ + "/v1/repos/JSONbored/gittensory/intelligence": { + repo: { fullName: "JSONbored/gittensory", installationId: 789 }, + queueHealth: { level: "low", signals: {} }, + }, + "/v1/repos/JSONbored/gittensory/settings": { + commentMode: "off", + autoLabelEnabled: false, + checkRunMode: "disabled", + }, + }); + + const command = await runRaycastMaintainerQueueCommand({ client, repoInput: "JSONbored/gittensory" }); + + expect(command.publicSurface).toMatchObject({ + commentMode: "off", + labelMode: "disabled", + checkMode: "disabled", + }); + expect(command.installHealth).toMatchObject({ + status: "unavailable", + installationId: 789, + details: ["Installation health is unavailable from the current API response."], + }); + }); + + it("explains missing installation permissions for the install-health command", async () => { + const { client } = fakeRaycastClient({ + "/v1/repos/JSONbored/gittensory/intelligence": { + repo: { fullName: "JSONbored/gittensory", installationId: 456 }, + }, + "/v1/installations/456/health": { + status: "needs_attention", + missingPermissions: ["issues:write", "checks:write"], + missingEvents: ["pull_request"], + }, + }); + + const result = await runRaycastInstallHealthCommand({ client, repoInput: "JSONbored/gittensory" }); + + expect(result.installHealth).toMatchObject({ + status: "needs_attention", + installationId: 456, + missingPermissions: ["issues:write", "checks:write"], + missingEvents: ["pull_request"], + }); + expect(result.installHealth.details.join("\n")).toContain("Missing GitHub App permission: issues:write."); + expect(result.installHealth.nextActions.join("\n")).toContain("Grant issues:write permission"); + }); + + it("marks install health as not installed when repo intelligence has no installation id", async () => { + const { client } = fakeRaycastClient({ + "/v1/repos/JSONbored/gittensory/intelligence": { repo: { fullName: "JSONbored/gittensory" } }, + }); + + const result = await runRaycastInstallHealthCommand({ client, repoInput: "JSONbored/gittensory" }); + + expect(result.installHealth).toMatchObject({ + status: "not_installed", + installationId: null, + }); + }); + + it("marks install health as unavailable when the health endpoint is absent", async () => { + const { client } = fakeRaycastClient({ + "/v1/repos/JSONbored/gittensory/intelligence": { + repo: { fullName: "JSONbored/gittensory", installationId: 321 }, + }, + }); + + const result = await runRaycastInstallHealthCommand({ client, repoInput: "JSONbored/gittensory" }); + + expect(result.installHealth).toMatchObject({ + status: "unavailable", + installationId: 321, + nextActions: ["Refresh installation health, then retry the Raycast command."], + }); + }); + + it("runs public preview through the preview endpoint without posting, labels, checks, or source upload", async () => { + const { client, calls } = fakeRaycastClient({ + "/v1/app/commands/preview": { + preview: { + body: "Checks are passing. Ready for review.", + warnings: [], + decision: { + status: "ready", + willComment: true, + willLabel: false, + willCheckRun: false, + }, + }, + }, + }); + + const result = await runRaycastPublicPreviewCommand({ + client, + repoInput: "JSONbored/gittensory", + pullNumber: 42, + maintainerLogin: "jsonbored", + }); + + expect(result).toMatchObject({ + command: "public_preview", + pullNumber: 42, + body: "Checks are passing. Ready for review.", + privacy: { + previewOnly: true, + sourceUpload: false, + githubMutations: false, + publicPacketIncludesPrivateContext: false, + }, + }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ method: "POST", path: "/v1/app/commands/preview" }); + expect(calls[0]?.body).toMatchObject({ + command: "@gittensory queue-summary", + repoFullName: "JSONbored/gittensory", + pullNumber: 42, + sample: { commenterLogin: "jsonbored", commenterAssociation: "OWNER" }, + }); + expect(calls.map((call) => call.path).join("\n")).not.toMatch(/comments|labels|check-runs|source/i); + }); + + it("rejects invalid preview pull numbers before making API requests", async () => { + const { client, calls } = fakeRaycastClient({}); + + await expect( + runRaycastPublicPreviewCommand({ + client, + repoInput: "JSONbored/gittensory", + pullNumber: 0, + }), + ).rejects.toThrow(/positive pull request number/i); + expect(calls).toHaveLength(0); + }); + + it("surfaces API errors cleanly for preview commands", async () => { + const { client } = fakeRaycastClient({}); + + await expect( + runRaycastPublicPreviewCommand({ + client, + repoInput: "JSONbored/gittensory", + pullNumber: 1, + }), + ).rejects.toThrow("not_found"); + }); + + it("does not copy private reviewability, score, wallet, or payout language into the public preview packet", async () => { + const { client } = fakeRaycastClient({ + "/v1/app/commands/preview": { + preview: { + body: "private reviewability 91/100, wallet, hotkey, payout, reward estimate, and scoreability should not leak.", + warnings: [], + decision: { status: "ready", willComment: true }, + }, + }, + }); + + const result = await runRaycastPublicPreviewCommand({ + client, + repoInput: "JSONbored/gittensory", + pullNumber: 7, + }); + + expect(result.body).not.toMatch(/private reviewability|wallet|hotkey|payout|reward estimate|scoreability/i); + expect(result.privacy.publicPacketIncludesPrivateContext).toBe(false); + }); +}); + +function fakeRaycastClient(routes: Record): { + client: RaycastApiClient; + calls: Array<{ method: string; path: string; headers: Record; body: unknown }>; +} { + const calls: Array<{ method: string; path: string; headers: Record; body: unknown }> = []; + const fetchImpl: RaycastCommandFetch = vi.fn(async (input, init) => { + const url = new URL(input); + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(init.body) : null; + calls.push({ method, path: url.pathname, headers: init?.headers ?? {}, body }); + if (!(url.pathname in routes)) return jsonResponse(404, { error: "not_found" }); + return jsonResponse(200, routes[url.pathname]); + }); + return { + client: { + apiOrigin: "https://api.gittensory.test", + sessionToken: TOKEN, + fetchImpl, + }, + calls, + }; +} + +function jsonResponse(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Not found", + async json() { + return body; + }, + }; +}