diff --git a/apps/web/src/app/studio/__tests__/actions.test.ts b/apps/web/src/app/studio/__tests__/actions.test.ts new file mode 100644 index 000000000..e8c338b8c --- /dev/null +++ b/apps/web/src/app/studio/__tests__/actions.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/github-pr-client', () => ({ + createOrUpdatePullRequestsForApprovedSpecs: vi.fn(), +})); + +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => new Headers()), +})); + +vi.mock('@/lib/billing/billing-context', () => ({ + resolveTrustedBillingEmail: vi.fn(async () => 'pro@example.com'), +})); + +vi.mock('@/lib/billing/entitlement-store', () => ({ + isProSubscriber: vi.fn(async () => true), +})); + +import { openGitHubPrsForApprovedSpecs } from '@/app/studio/actions'; +import { createOrUpdatePullRequestsForApprovedSpecs } from '@/lib/github-pr-client'; +import { isProSubscriber } from '@/lib/billing/entitlement-store'; + +const mockedOpen = vi.mocked(createOrUpdatePullRequestsForApprovedSpecs); +const mockedIsPro = vi.mocked(isProSubscriber); + +describe('openGitHubPrsForApprovedSpecs', () => { + const originalToken = process.env.GITHUB_TOKEN; + const originalRepo = process.env.GITHUB_REPOSITORY; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_TOKEN = 'test-token'; + process.env.GITHUB_REPOSITORY = 'octo/eventrelay'; + }); + + afterEach(() => { + if (originalToken === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = originalToken; + if (originalRepo === undefined) delete process.env.GITHUB_REPOSITORY; + else process.env.GITHUB_REPOSITORY = originalRepo; + }); + + it('refuses callers without a Pro entitlement', async () => { + mockedIsPro.mockResolvedValueOnce(false); + + const result = await openGitHubPrsForApprovedSpecs([ + { id: 'spec-1', title: 'Approved', body: 'Body', head: 'spec/approved', approved: true }, + ]); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/pro subscription/i); + expect(mockedOpen).not.toHaveBeenCalled(); + }); + + it('fails closed when no spec is approved', async () => { + const result = await openGitHubPrsForApprovedSpecs([ + { id: 'spec-1', title: 'Spec', body: 'Body', head: 'spec/one', approved: false }, + ]); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/approve at least one spec/i); + expect(mockedOpen).not.toHaveBeenCalled(); + }); + + it('calls the GitHub write flow with only approved specs', async () => { + mockedOpen.mockResolvedValue([ + { specId: 'spec-1', number: 10, htmlUrl: 'https://github.com/o/r/pull/10', action: 'created' }, + ]); + + const result = await openGitHubPrsForApprovedSpecs([ + { id: 'spec-1', title: 'Approved', body: 'Body', head: 'spec/approved', approved: true }, + { id: 'spec-2', title: 'Skipped', body: 'Body', head: 'spec/skipped', approved: false }, + ]); + + expect(result.ok).toBe(true); + expect(mockedOpen).toHaveBeenCalledWith( + [ + { id: 'spec-1', title: 'Approved', body: 'Body', head: 'spec/approved', approved: true }, + ], + expect.objectContaining({ + owner: 'octo', + repo: 'eventrelay', + token: 'test-token', + }), + ); + }); + + it('fails closed when GitHub integration is not configured', async () => { + delete process.env.GITHUB_TOKEN; + const result = await openGitHubPrsForApprovedSpecs([ + { id: 'spec-1', title: 'Approved', body: 'Body', head: 'spec/approved', approved: true }, + ]); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not configured/i); + expect(mockedOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/studio/actions.ts b/apps/web/src/app/studio/actions.ts new file mode 100644 index 000000000..a86fda2c2 --- /dev/null +++ b/apps/web/src/app/studio/actions.ts @@ -0,0 +1,95 @@ +'use server'; + +import 'server-only'; +import { headers } from 'next/headers'; +import { + createOrUpdatePullRequestsForApprovedSpecs, + type ApprovedSpecPullRequestInput, + type GitHubPullRequestWriteResult, +} from '@/lib/github-pr-client'; +import { resolveTrustedBillingEmail } from '@/lib/billing/billing-context'; +import { isProSubscriber } from '@/lib/billing/entitlement-store'; + +export interface OpenGitHubPrsResult { + ok: boolean; + error?: string; + pullRequests: GitHubPullRequestWriteResult[]; +} + +function parseRepository(value: string): { owner: string; repo: string } | null { + const [owner, repo, ...rest] = value.trim().split('/'); + if (!owner || !repo || rest.length > 0) return null; + return { owner, repo }; +} + +/** + * Resolve the trusted billing identity for this server action. + * + * Server actions POST to the page path, and `/studio` is intentionally public + * (see auth-paths.ts), so the middleware auth gate does *not* run here. Without + * an explicit check any anonymous visitor could invoke this action and drive + * GitHub writes with the server's GITHUB_TOKEN. We rebuild a minimal Request + * from the incoming cookies so the same trusted-identity resolution used by + * privileged API routes (session email → signed billing cookie) applies here. + */ +async function resolveActionBillingEmail(): Promise { + const cookieHeader = (await headers()).get('cookie') ?? ''; + const request = new Request('https://studio.internal/studio', { + headers: { cookie: cookieHeader }, + }); + return resolveTrustedBillingEmail(request); +} + +export async function openGitHubPrsForApprovedSpecs( + specs: ApprovedSpecPullRequestInput[], +): Promise { + // Authorize before touching any server-side GitHub credentials. Opening PRs + // in the configured repository is a privileged, Pro-gated action — the same + // entitlement class as agent dispatch — so anonymous/free callers are refused + // before any GitHub API request is made. + const billingEmail = await resolveActionBillingEmail(); + if (!(await isProSubscriber(billingEmail))) { + return { + ok: false, + error: 'Opening GitHub pull requests requires a Pro subscription. Upgrade at /pricing.', + pullRequests: [], + }; + } + + const approvedSpecs = specs.filter((spec) => spec.approved); + if (approvedSpecs.length === 0) { + return { + ok: false, + error: 'Approve at least one spec before opening GitHub pull requests.', + pullRequests: [], + }; + } + + const token = (process.env.GITHUB_TOKEN || '').trim(); + const repository = parseRepository(process.env.GITHUB_REPOSITORY || ''); + if (!token || !repository) { + return { + ok: false, + error: 'GitHub integration is not configured.', + pullRequests: [], + }; + } + + try { + const pullRequests = await createOrUpdatePullRequestsForApprovedSpecs( + approvedSpecs, + { + owner: repository.owner, + repo: repository.repo, + token, + }, + ); + return { ok: true, pullRequests }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Could not open GitHub pull requests.', + pullRequests: [], + }; + } +} diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index de597b434..43963985d 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -3,7 +3,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { Download, Play, Rocket } from 'lucide-react'; +import { Download, GitPullRequest, Play, Rocket } from 'lucide-react'; import { formatSeconds, parseTimestampToSeconds, extractYouTubeId } from '@/lib/timestamp'; import { applyPackStackChecks, compileLinkedSop, type LinkedSop } from '@/lib/linked-sop'; import { @@ -77,6 +77,7 @@ import { import { CANONICAL_STUDIO_PATH } from '@/lib/auth-paths'; import type { ExtractedEvent } from '@/lib/types'; import type { VideoPackArchitecture, VideoPackArtifact } from '@/lib/video-pack-types'; +import { openGitHubPrsForApprovedSpecs } from '@/app/studio/actions'; const FIXTURE = 'https://www.youtube.com/watch?v=auJzb1D-fag'; @@ -101,6 +102,15 @@ function getYouTubeId(url: string) { return extractYouTubeId(url) || ''; } +function toSpecBranch(videoId: string, stepId: string): string { + const slug = `${videoId || 'video'}-${stepId}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return `spec/${slug || 'approved'}`; +} + function mapExtractedEvents(raw: unknown[], videoId: string): ExtractedEvent[] { return raw .filter((item): item is Record => Boolean(item) && typeof item === 'object') @@ -236,6 +246,8 @@ export default function OneLoopStudio({ const [deployReceiptVideoId, setDeployReceiptVideoId] = useState(null); const [gateReceipt, setGateReceipt] = useState(null); const [completedChecks, setCompletedChecks] = useState([]); + const [approvedSpecIds, setApprovedSpecIds] = useState([]); + const [openingPrs, setOpeningPrs] = useState(false); const [playerEpoch, setPlayerEpoch] = useState(0); const [exportToast, setExportToast] = useState<{ tone: 'success' | 'error'; text: string } | null>( null, @@ -292,6 +304,7 @@ export default function OneLoopStudio({ useEffect(() => { setCompletedChecks([]); + setApprovedSpecIds([]); setDeployReceiptUrl(null); setDeployReceiptVideoId(null); }, [selectedVideoId]); @@ -655,6 +668,35 @@ export default function OneLoopStudio({ } }; + const openApprovedSpecsPrs = async () => { + if (!linkedSop || linkedSop.steps.length === 0) { + setMessage('Analyze a video with SOP steps before opening GitHub pull requests.'); + return; + } + const sourceVideoId = getYouTubeId(selected?.url || url || ''); + const specs = linkedSop.steps.map((step) => ({ + id: step.id, + title: step.title, + body: [step.description || '', step.quote ? `\nQuote: ${step.quote}` : ''].join('').trim(), + head: toSpecBranch(sourceVideoId, step.id), + base: 'main', + approved: approvedSpecIds.includes(step.id), + })); + setOpeningPrs(true); + try { + const result = await openGitHubPrsForApprovedSpecs(specs); + if (!result.ok) { + setMessage(result.error || 'Could not open GitHub pull requests for approved specs.'); + return; + } + setMessage(`Opened or updated ${result.pullRequests.length} GitHub pull request(s) for approved specs.`); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Could not open GitHub pull requests.'); + } finally { + setOpeningPrs(false); + } + }; + const transcriptStage = studioTranscriptStage({ busy: transcriptWorking, elapsedSeconds: elapsed, @@ -1008,7 +1050,9 @@ export default function OneLoopStudio({ {linkedSop.steps.length === 0 && (
  • No ordered SOP in this run.
  • )} - {linkedSop.steps.map((step) => ( + {linkedSop.steps.map((step) => { + const approved = approvedSpecIds.includes(step.id); + return (
  • {step.timestamp != null ? (
  • - ))} + ); + })} {stackChecks.length > 0 && ( @@ -1256,6 +1316,17 @@ export default function OneLoopStudio({ {deployBusy ? 'Attempting deploy…' : studioDeployButtonLabel(Boolean(scopedDeployReceipt))} + {holdReason && (

    {holdReason} diff --git a/apps/web/src/lib/__tests__/github-pr-client.test.ts b/apps/web/src/lib/__tests__/github-pr-client.test.ts new file mode 100644 index 000000000..0ab4dea8b --- /dev/null +++ b/apps/web/src/lib/__tests__/github-pr-client.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createOrUpdatePullRequest, + createOrUpdatePullRequestsForApprovedSpecs, + type ApprovedSpecPullRequestInput, +} from '@/lib/github-pr-client'; + +describe('github-pr-client', () => { + it('creates a pull request when no open PR exists for the spec branch', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ number: 7, html_url: 'https://github.com/o/r/pull/7' }), { + status: 201, + }), + ); + + const result = await createOrUpdatePullRequest( + { + id: 'spec-1', + title: 'Add onboarding workflow', + body: '## Spec\n\n- Add onboarding flow', + head: 'spec/add-onboarding-workflow', + base: 'main', + }, + { owner: 'octo', repo: 'eventrelay', token: 'token', fetchImpl }, + ); + + expect(result).toEqual( + expect.objectContaining({ + specId: 'spec-1', + number: 7, + htmlUrl: 'https://github.com/o/r/pull/7', + action: 'created', + }), + ); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(String(fetchImpl.mock.calls[0]?.[0])).toContain('/repos/octo/eventrelay/pulls?'); + expect(String(fetchImpl.mock.calls[1]?.[0])).toContain('/repos/octo/eventrelay/pulls'); + const body = JSON.parse(String((fetchImpl.mock.calls[1]?.[1] as RequestInit).body)); + expect(body).toMatchObject({ + title: 'Add onboarding workflow', + head: 'spec/add-onboarding-workflow', + base: 'main', + }); + }); + + it('updates the existing pull request when one already exists for the spec branch', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([{ number: 42, html_url: 'https://github.com/o/r/pull/42' }]), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ number: 42, html_url: 'https://github.com/o/r/pull/42' }), { + status: 200, + }), + ); + + const result = await createOrUpdatePullRequest( + { + id: 'spec-2', + title: 'Revise payment handoff', + body: 'Updated spec body', + head: 'spec/revise-payment-handoff', + base: 'main', + }, + { owner: 'octo', repo: 'eventrelay', token: 'token', fetchImpl }, + ); + + expect(result.action).toBe('updated'); + expect(result.number).toBe(42); + expect(String(fetchImpl.mock.calls[1]?.[0])).toContain('/repos/octo/eventrelay/pulls/42'); + const body = JSON.parse(String((fetchImpl.mock.calls[1]?.[1] as RequestInit).body)); + expect(body).toMatchObject({ + title: 'Revise payment handoff', + body: 'Updated spec body', + base: 'main', + }); + }); + + it('opens pull requests only for approved specs', async () => { + const specs: ApprovedSpecPullRequestInput[] = [ + { + id: 'spec-approved', + title: 'Approved spec', + body: 'Approved body', + head: 'spec/approved', + approved: true, + }, + { + id: 'spec-unapproved', + title: 'Unapproved spec', + body: 'Unapproved body', + head: 'spec/unapproved', + approved: false, + }, + ]; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ number: 9, html_url: 'https://github.com/o/r/pull/9' }), { + status: 201, + }), + ); + + const result = await createOrUpdatePullRequestsForApprovedSpecs(specs, { + owner: 'octo', + repo: 'eventrelay', + token: 'token', + fetchImpl, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.specId).toBe('spec-approved'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/lib/github-pr-client.ts b/apps/web/src/lib/github-pr-client.ts new file mode 100644 index 000000000..8a65b5a85 --- /dev/null +++ b/apps/web/src/lib/github-pr-client.ts @@ -0,0 +1,138 @@ +import 'server-only'; + +export interface ApprovedSpecPullRequestInput { + id: string; + title: string; + body: string; + head: string; + base?: string; + approved: boolean; +} + +export interface GitHubPullRequestWriteResult { + specId: string; + number: number; + htmlUrl: string; + action: 'created' | 'updated'; +} + +export interface GitHubPullRequestClientConfig { + owner: string; + repo: string; + token: string; + fetchImpl?: typeof fetch; +} + +interface GitHubPullRequestPayload { + number?: unknown; + html_url?: unknown; +} + +function apiBase(config: GitHubPullRequestClientConfig): string { + return `https://api.github.com/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}`; +} + +function githubHeaders(token: string): Record { + return { + Accept: 'application/vnd.github+json', + Authorization: ['Bearer', token].join(' '), + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }; +} + +function readResult( + payload: GitHubPullRequestPayload | null, + specId: string, + action: 'created' | 'updated', +): GitHubPullRequestWriteResult { + const number = typeof payload?.number === 'number' ? payload.number : 0; + const htmlUrl = typeof payload?.html_url === 'string' ? payload.html_url : ''; + if (!number || !htmlUrl) { + throw new Error(`GitHub ${action} response was missing pull request fields for spec "${specId}"`); + } + return { specId, number, htmlUrl, action }; +} + +async function parsePayload(response: Response): Promise { + return (await response.json().catch(() => null)) as GitHubPullRequestPayload | null; +} + +export async function createOrUpdatePullRequest( + spec: Omit, + config: GitHubPullRequestClientConfig, +): Promise { + const fetchImpl = config.fetchImpl ?? fetch; + const base = spec.base?.trim() || 'main'; + const encodedHead = encodeURIComponent(`${config.owner}:${spec.head}`); + const encodedBase = encodeURIComponent(base); + const listUrl = `${apiBase(config)}/pulls?state=open&head=${encodedHead}&base=${encodedBase}&per_page=1`; + + const list = await fetchImpl(listUrl, { + headers: githubHeaders(config.token), + method: 'GET', + signal: AbortSignal.timeout(30_000), + }); + if (!list.ok) { + const detail = await list.text(); + throw new Error(`Could not list open pull requests (${list.status}): ${detail}`); + } + + const open = (await list.json().catch(() => [])) as Array<{ number?: unknown }>; + const existingNumber = typeof open[0]?.number === 'number' ? open[0].number : null; + + if (existingNumber != null) { + const update = await fetchImpl(`${apiBase(config)}/pulls/${existingNumber}`, { + headers: githubHeaders(config.token), + method: 'PATCH', + body: JSON.stringify({ title: spec.title, body: spec.body, base }), + signal: AbortSignal.timeout(30_000), + }); + if (!update.ok) { + const detail = await update.text(); + throw new Error(`Could not update pull request #${existingNumber} (${update.status}): ${detail}`); + } + return readResult(await parsePayload(update), spec.id, 'updated'); + } + + const created = await fetchImpl(`${apiBase(config)}/pulls`, { + headers: githubHeaders(config.token), + method: 'POST', + body: JSON.stringify({ + title: spec.title, + body: spec.body, + head: spec.head, + base, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!created.ok) { + const detail = await created.text(); + throw new Error(`Could not create pull request for spec "${spec.id}" (${created.status}): ${detail}`); + } + + return readResult(await parsePayload(created), spec.id, 'created'); +} + +export async function createOrUpdatePullRequestsForApprovedSpecs( + specs: ApprovedSpecPullRequestInput[], + config: GitHubPullRequestClientConfig, +): Promise { + const approvedSpecs = specs.filter((spec) => spec.approved); + const results: GitHubPullRequestWriteResult[] = []; + for (const spec of approvedSpecs) { + results.push( + await createOrUpdatePullRequest( + { + id: spec.id, + title: spec.title, + body: spec.body, + head: spec.head, + base: spec.base, + }, + config, + ), + ); + } + return results; +}