diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 288becb..a5ef8ec 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -100,6 +100,27 @@ Example: - `project link` +### `connect` + +Connect a project to an external provider integration. + +`connect` is different from `link`: + +- `link` binds local repo context to an existing Prisma resource +- `connect` configures remote project metadata for an external integration + +Example: + +- `project connect-repo` + +### `disconnect` + +Remove a project-level external provider integration. + +Example: + +- `project disconnect-repo` + ### `deploy` Build and release an app into a target branch. diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4f2eb25..e8eaa09 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -22,6 +22,9 @@ Out of scope for the current preview: - `migrate` - product-specific namespaces such as `compute` +The GitHub branch automation slice extends the `project` group with repository +connection commands. It does not add a top-level `git` or `github` group. + ## Global Rules - Canonical shape is `prisma `. @@ -42,6 +45,39 @@ Out of scope for the current preview: - `prisma.config.ts` stores only the linked project id. - Remote commands do not silently change local context. +## Current Provider-Backed Prototype + +The long-term MVP command model remains environment-based. + +The current real provider does not yet expose first-class environment resources or source-revision promotion semantics through the Management API. Until that changes, the real-provider prototype uses this temporary contract: + +- fixture mode continues to model the target MVP `env` behavior +- real-provider mode currently supports linked `project`, selected `app`, and `deployment` +- real-provider mode does not yet support project repository connection APIs +- fixture mode stores project repository connections in local CLI state to validate the command language +- local prototype app selection lives in `.prisma/cli/state.json`, keyed by linked project +- local prototype release flows also persist the last deployment this CLI believes is live for each project/app +- when no saved live deployment exists yet, the prototype falls back to treating the most recent deployment as live +- `prisma.config.ts` remains project-only +- `env list`, `env show`, and `env use` return `FEATURE_UNAVAILABLE` in real-provider mode +- `app logs` resolves deployments in the current app/deployment prototype model and streams provider-backed logs +- `app promote [--app ]` switches which deployment is live for the selected app +- `app rollback [--app ] [--to ]` switches the selected app back to an earlier deployment +- provider compute service/version resources remain implementation detail and are not surfaced as CLI nouns + +## Interactivity and Confirmation + +- Commands default to TTY-aware interaction behavior unless a command is intentionally non-interactive. +- `--interactive` and `--no-interactive` override that default. +- `-y` and `--yes` accept confirmation prompts when a command supports confirmation. +- `--json` and non-interactive mode never block on a prompt. + +When confirmation is required and a prompt cannot be shown: + +- the command must fail with a structured actionable error +- the error should explain what required confirmation +- the error should suggest `-y` when `-y` is a valid bypass + ## Context Resolution ### Project @@ -238,6 +274,185 @@ prisma-cli project link prisma-cli project link proj_123 ``` +## `prisma-cli project connect-repo [git-url]` + +Purpose: + +- connect the linked Prisma project to a GitHub repository + +Behavior: + +- requires auth +- requires an existing linked project +- in the current real-provider prototype, returns `FEATURE_UNAVAILABLE` +- the behavior below remains the target M2 contract and current fixture-mode contract +- if `[git-url]` is provided, parses it as a GitHub repository URL +- if `[git-url]` is omitted, reads the local Git `origin` remote URL +- accepts common GitHub URL forms such as: + - `https://github.com/prisma/prisma-cli` + - `https://github.com/prisma/prisma-cli.git` + - `git@github.com:prisma/prisma-cli.git` +- rejects unsupported providers with `REPO_PROVIDER_UNSUPPORTED` +- writes the repository connection as project metadata on the platform +- does not write the repository connection to `prisma.config.ts` +- does not create branches synchronously +- enables future branch and pull request webhook automation for that project + +Current fixture-mode note: + +- the repo connection is stored in `.prisma/cli/state.json`, keyed by linked project +- this local storage is a prototype stand-in for platform project metadata + +In `--json`, `result` uses this stable shape: + +```json +{ + "linkedProjectId": "proj_123", + "workspace": { + "id": "ws_123", + "name": "Acme Inc" + }, + "project": { + "id": "proj_123", + "name": "Acme Dashboard" + }, + "repositoryConnection": { + "provider": "github", + "repository": { + "owner": "prisma", + "name": "prisma-cli", + "url": "https://github.com/prisma/prisma-cli" + }, + "automation": { + "branches": true, + "pullRequests": true, + "comments": true + }, + "installation": { + "status": "pending", + "id": null + } + } +} +``` + +Rules: + +- `repositoryConnection.provider` is `github` in the MVP +- `installation.status` may be `pending` until the GitHub App installation callback is complete +- branch automation begins only after installation access and repository mapping are complete + +Help: + +- description: `Connect the linked project to a GitHub repository.` +- examples: + - `prisma-cli project connect-repo` + - `prisma-cli project connect-repo git@github.com:prisma/prisma-cli.git` + +Example human output: + +```text +project connect-repo → Connecting the linked project to a GitHub repository. + +│ project: Acme Dashboard +│ workspace: Acme Inc +│ repository: prisma/prisma-cli +│ provider: GitHub +│ mode: apply +│ +│ Read more docs/product/command-spec.md#prisma-project-connect-repo-git-url + +◇ Applying repository connection... +✔ Applied 1 operation(s) + Branch automation is now pending GitHub App installation. +``` + +## `prisma-cli project disconnect-repo` + +Purpose: + +- disconnect the GitHub repository from the linked Prisma project + +Behavior: + +- requires auth +- requires an existing linked project +- in the current real-provider prototype, returns `FEATURE_UNAVAILABLE` +- the behavior below remains the target M2 contract and current fixture-mode contract +- removes the project repository connection +- stops future branch and pull request automation for that project +- does not delete the linked Prisma project +- does not delete `prisma.config.ts` +- does not delete historical branches synchronously +- retention for existing branches follows the documented branch retention policy + +Current fixture-mode note: + +- clears the local repo connection from `.prisma/cli/state.json` + +In `--json`, `result` uses the same shape as `project connect-repo`, with +`repositoryConnection: null`. + +Help: + +- description: `Disconnect the GitHub repository from the linked project.` +- examples: + - `prisma-cli project disconnect-repo` + +Example human output: + +```text +project disconnect-repo → Disconnecting the GitHub repository from the linked project. + +│ project: Acme Dashboard +│ workspace: Acme Inc +│ repository: prisma/prisma-cli +│ mode: apply +│ +│ Read more docs/product/command-spec.md#prisma-project-disconnect-repo + +◇ Applying repository disconnection... +✔ Applied 1 operation(s) + Branch automation is no longer active for this project. +``` + +## GitHub Branch Automation + +GitHub branch automation is platform behavior enabled by `project connect-repo`. + +Subscribed GitHub App events: + +- `installation` +- `installation_repositories` +- `push` +- `pull_request` + +Webhook rules: + +- webhook signatures must be validated before processing +- webhook delivery ids must be stored so repeated delivery is idempotent +- `push` events for `refs/heads/` with `deleted: false` upsert the matching Prisma branch +- `push` events for `refs/heads/` with `deleted: true` move the matching Prisma branch into the documented retention state +- `pull_request` events with `opened`, `reopened`, `synchronize`, or `edited` upsert the matching Prisma branch and attach PR metadata +- `pull_request` events with `closed` update PR metadata and then apply the branch retention policy +- branch rename is treated as delete/create unless a reliable provider event is available +- fork pull requests do not create deployable branches in M2 unless explicitly allowed by a later security decision + +PR comment rules: + +- comments use the GitHub issue comment API because pull requests are issues +- comments must be sticky and updated in place using a hidden marker +- comments must clearly say that branch sync succeeded while preview deployment is pending M3 +- inability to write a comment should not roll back branch sync + +M2 non-goals: + +- app build system +- automatic Compute deployment +- preview URL going live +- applying schema from source into preview DB +- build logs + ## `prisma-cli branch list` Purpose: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index b05d558..c07477a 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -158,6 +158,9 @@ These codes are the minimum stable set for the MVP: - `CONFIRMATION_REQUIRED` - `REMOVE_FAILED` - `FEATURE_UNAVAILABLE` +- `REPO_NOT_CONNECTED` +- `REPO_PROVIDER_UNSUPPORTED` +- `LOGS_FAILED` - `BUILD_FAILED` - `RUN_FAILED` - `DEPLOY_FAILED` @@ -177,6 +180,9 @@ Recommended meanings: - `CONFIRMATION_REQUIRED`: command cannot continue without confirmation in the current mode - `REMOVE_FAILED`: app removal could not complete remotely - `FEATURE_UNAVAILABLE`: the command exists in the CLI model, but the current preview cannot support it yet +- `REPO_NOT_CONNECTED`: a command expected a project repository connection, but none exists +- `REPO_PROVIDER_UNSUPPORTED`: a repository URL could be parsed, but the provider is not supported by the MVP +- `LOGS_FAILED`: log streaming could not be started or completed cleanly - `BUILD_FAILED`: build failed before a healthy deployment existed - `RUN_FAILED`: local framework run command could not be started or exited unsuccessfully - `DEPLOY_FAILED`: deployment or post-build health failed diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 89d2970..15df342 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -71,6 +71,8 @@ Current MVP commands map to patterns like this: | `branch list` | `list` | | `branch show` | `show` | | `branch use` | `mutate` | +| `project connect-repo` | `mutate` | +| `project disconnect-repo` | `mutate` | No current MVP command uses `verify` or `inspect`, but new commands must still choose one existing pattern rather than inventing a new one casually. diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index 6a124aa..7473589 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -112,6 +112,63 @@ Rules: - the app may be selected or created as part of app deployment workflows - app selection is local CLI state when needed for the preview package +### Repository Connection + +`repository connection` is the remote source-control binding for a Prisma project. + +M2 relevance: + +- connects a Prisma project to a GitHub repository +- enables branch and pull request webhook automation +- is stored as platform project metadata, not in `prisma.config.ts` +- does not imply app build, deploy, preview URL, schema application, or database provisioning + +MVP rules: + +- only GitHub is supported +- the CLI may infer a repository from the local `origin` remote +- users may also pass an explicit GitHub repository URL +- disconnecting a repository stops future GitHub automation for the project +- historical branches may remain according to the retention policy + +Current provider-backed prototype note: + +- platform repo-connection APIs are not available in this repo +- fixture mode stores the connection in local CLI state so the intended command language can be validated +- real-provider mode returns `FEATURE_UNAVAILABLE` until platform APIs exist + +### Branch + +`branch` is the project-scoped Prisma representation of a remote source branch. + +M2 relevance: + +- created or updated from GitHub branch and pull request webhook events +- records source metadata such as repository, branch name, head commit, and pull request link +- is the future attachment point for preview databases, apps, and deployments +- is not itself a deployment + +M2 rules: + +- pushing a remote GitHub branch creates or updates the matching Prisma branch +- opening or updating a pull request links PR metadata to the matching Prisma branch +- repeated webhook deliveries must be idempotent +- branch deletion or PR closure moves the Prisma branch into the documented retention state +- if branch retention is not yet decided, deletion and closure must surface as an open product dependency rather than silently deleting data + +Non-goals for M2: + +- app build +- automatic Compute deployment +- preview URL going live +- applying schema from source into a preview database +- build logs + +Current provider-backed prototype note: + +- branch webhook ingestion belongs to the platform backend, not the local CLI package +- the CLI only starts or removes the project repository connection + ### Deployment `deployment` is one build-and-release instance of an app. @@ -170,6 +227,7 @@ future schema, database, and migration workflows awkward. ```text workspace -> project +project -> repository connection project -> branch branch -> app* branch -> database* diff --git a/packages/cli/README.md b/packages/cli/README.md index c000c53..f1053fd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -13,6 +13,7 @@ Run: ```bash pnpm prisma-cli --help pnpm prisma-cli auth login +pnpm prisma-cli project connect-repo git@github.com:org/repo.git pnpm prisma-cli app deploy --env DATABASE_URL=postgresql://example pnpm prisma-cli app list-env ``` diff --git a/packages/cli/src/adapters/git.ts b/packages/cli/src/adapters/git.ts new file mode 100644 index 0000000..8654f32 --- /dev/null +++ b/packages/cli/src/adapters/git.ts @@ -0,0 +1,77 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface ParsedGitHubRepository { + provider: "github"; + owner: string; + name: string; + url: string; +} + +export async function readGitOriginRemote(cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["config", "--get", "remote.origin.url"], { + cwd, + encoding: "utf8", + }); + const remote = stdout.trim(); + return remote.length > 0 ? remote : null; + } catch { + return null; + } +} + +export function parseGitHubRepositoryUrl(url: string): ParsedGitHubRepository | null { + const trimmed = url.trim(); + if (trimmed.length === 0) { + return null; + } + + const sshMatch = trimmed.match(/^git@github\.com:([^/\s]+)\/(.+?)$/); + if (sshMatch?.[1] && sshMatch[2]) { + return toGitHubRepository(sshMatch[1], sshMatch[2]); + } + + const sshUrlMatch = trimmed.match(/^ssh:\/\/git@github\.com\/([^/\s]+)\/(.+?)$/); + if (sshUrlMatch?.[1] && sshUrlMatch[2]) { + return toGitHubRepository(sshUrlMatch[1], sshUrlMatch[2]); + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + + if (parsed.hostname !== "github.com") { + return null; + } + + const [owner, repo, ...rest] = parsed.pathname.split("/").filter(Boolean); + if (!owner || !repo || rest.length > 0) { + return null; + } + + return toGitHubRepository(owner, repo); +} + +function toGitHubRepository(owner: string, rawName: string): ParsedGitHubRepository | null { + const name = rawName.replace(/\.git$/, ""); + if (!isValidGitHubPathPart(owner) || !isValidGitHubPathPart(name)) { + return null; + } + + return { + provider: "github", + owner, + name, + url: `https://github.com/${owner}/${name}`, + }; +} + +function isValidGitHubPathPart(value: string): boolean { + return /^[A-Za-z0-9_.-]+$/.test(value); +} diff --git a/packages/cli/src/adapters/local-state.ts b/packages/cli/src/adapters/local-state.ts index 5da7930..44d7d2a 100644 --- a/packages/cli/src/adapters/local-state.ts +++ b/packages/cli/src/adapters/local-state.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { AuthProviderId } from "../types/auth"; +import type { GitRepositoryConnection } from "../types/project"; export interface LocalState { auth: { @@ -12,6 +13,9 @@ export interface LocalState { branch: { active: string; }; + project: { + repositoryConnectionsByProject: Record; + }; app: { selectedByProject: Record; knownLiveDeploymentByProject: Record>; @@ -28,6 +32,9 @@ const DEFAULT_STATE: LocalState = { branch: { active: "preview", }, + project: { + repositoryConnectionsByProject: {}, + }, app: { selectedByProject: {}, knownLiveDeploymentByProject: {}, @@ -56,6 +63,9 @@ export class LocalStateStore { branch: { active: parsed.branch?.active ?? DEFAULT_STATE.branch.active, }, + project: { + repositoryConnectionsByProject: parsed.project?.repositoryConnectionsByProject ?? {}, + }, app: { selectedByProject: parsed.app?.selectedByProject ?? {}, knownLiveDeploymentByProject: parsed.app?.knownLiveDeploymentByProject ?? {}, @@ -96,6 +106,28 @@ export class LocalStateStore { return state; } + async readRepositoryConnection(projectId: string): Promise { + const state = await this.read(); + return state.project.repositoryConnectionsByProject[projectId] ?? null; + } + + async setRepositoryConnection( + projectId: string, + connection: GitRepositoryConnection, + ): Promise { + const state = await this.read(); + state.project.repositoryConnectionsByProject[projectId] = connection; + await this.write(state); + return state; + } + + async clearRepositoryConnection(projectId: string): Promise { + const state = await this.read(); + delete state.project.repositoryConnectionsByProject[projectId]; + await this.write(state); + return state; + } + async readSelectedApp(projectId: string): Promise { const state = await this.read(); return state.app.selectedByProject[projectId] ?? null; diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 1fcadb5..f9a70e7 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,17 +1,26 @@ import { Command } from "commander"; -import { runProjectLink, runProjectList, runProjectShow } from "../../controllers/project"; import { + runProjectConnectRepo, + runProjectDisconnectRepo, + runProjectLink, + runProjectList, + runProjectShow, +} from "../../controllers/project"; +import { + renderProjectConnectRepo, + renderProjectDisconnectRepo, renderProjectLink, renderProjectList, renderProjectShow, + serializeProjectRepositoryConnection, serializeProjectList, } from "../../presenters/project"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags"; import { runCommand } from "../../shell/command-runner"; import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime"; -import type { ProjectListResult, ProjectShowResult } from "../../types/project"; +import type { ProjectListResult, ProjectRepositoryConnectionResult, ProjectShowResult } from "../../types/project"; export function createProjectCommand(runtime: CliRuntime): Command { const project = attachCommandDescriptor(configureRuntimeCommand(new Command("project"), runtime), "project"); @@ -21,10 +30,65 @@ export function createProjectCommand(runtime: CliRuntime): Command { project.addCommand(createProjectListCommand(runtime)); project.addCommand(createProjectShowCommand(runtime)); project.addCommand(createProjectLinkCommand(runtime)); + project.addCommand(createProjectConnectRepoCommand(runtime)); + project.addCommand(createProjectDisconnectRepoCommand(runtime)); return project; } +function createProjectConnectRepoCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("connect-repo"), runtime), + "project.connect-repo", + ); + + command.argument("[git-url]", "GitHub repository URL"); + + addGlobalFlags(command); + + command.action(async (gitUrl: string | undefined, options) => { + await runCommand( + runtime, + "project.connect-repo", + options as Record, + (context) => runProjectConnectRepo(context, gitUrl), + { + renderHuman: (context, descriptor, result) => renderProjectConnectRepo(context, descriptor, result), + renderJson: (result) => serializeProjectRepositoryConnection(result), + }, + ); + }); + + return command; +} + +function createProjectDisconnectRepoCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("disconnect-repo"), runtime), + "project.disconnect-repo", + ); + + addGlobalFlags(command); + + command.action(async (options) => { + await runCommand( + runtime, + "project.disconnect-repo", + options as Record, + (context) => runProjectDisconnectRepo(context), + { + renderHuman: (context, descriptor, result) => renderProjectDisconnectRepo(context, descriptor, result), + renderJson: (result) => serializeProjectRepositoryConnection({ + ...result, + repositoryConnection: null, + }), + }, + ); + }); + + return command; +} + function createProjectListCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor(configureRuntimeCommand(new Command("list"), runtime), "project.list"); diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 64dcc83..997f075 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -1,14 +1,21 @@ -import { authRequiredError, CliError, usageError } from "../shell/errors"; +import { authRequiredError, CliError, featureUnavailableError, usageError } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; import { canPrompt, type CommandContext } from "../shell/runtime"; import type { AuthStateResult } from "../types/auth"; -import type { ProjectListResult, ProjectShowResult, ProjectSummary } from "../types/project"; +import type { + GitRepositoryConnection, + ProjectListResult, + ProjectRepositoryConnectionResult, + ProjectShowResult, + ProjectSummary, +} from "../types/project"; import { createAuthUseCases } from "../use-cases/auth"; import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways"; import { createProjectUseCases, projectNotFoundError } from "../use-cases/project"; import { requireAuthenticatedAuthState } from "./auth"; import { createSelectPromptPort } from "./select-prompt-port"; import { UnsafeConfigWriteError, readLinkedProjectId, writeLinkedProjectId } from "../adapters/config"; +import { parseGitHubRepositoryUrl, readGitOriginRemote } from "../adapters/git"; import { readAuthState } from "../lib/auth/auth-ops"; import { requireComputeAuth } from "../lib/auth/guard"; @@ -299,6 +306,99 @@ export async function runProjectLink( }; } +export async function runProjectConnectRepo( + context: CommandContext, + gitUrl: string | undefined, +): Promise> { + if (isRealMode(context)) { + throw repoConnectionUnavailableError(); + } + + const { authState, linkedProject } = await requireLinkedProjectForRepoCommand(context); + const remoteUrl = gitUrl ?? await readGitOriginRemote(context.runtime.cwd); + + if (!remoteUrl) { + throw usageError( + "Repository connection requires a GitHub repository URL", + "No git-url was provided and the local repo does not have an origin remote.", + "Pass a GitHub repository URL, or add a GitHub origin remote and rerun prisma-cli project connect-repo.", + ["prisma-cli project connect-repo git@github.com:prisma/prisma-cli.git"], + "project", + ); + } + + const repository = parseGitHubRepositoryUrl(remoteUrl); + if (!repository) { + throw new CliError({ + code: "REPO_PROVIDER_UNSUPPORTED", + domain: "project", + summary: "Repository provider is not supported", + why: "The MVP repository connection supports GitHub repository URLs only.", + fix: "Pass a GitHub repository URL such as git@github.com:prisma/prisma-cli.git.", + exitCode: 2, + nextSteps: ["prisma-cli project connect-repo git@github.com:owner/repo.git"], + }); + } + + const connection: GitRepositoryConnection = { + provider: "github", + repository: { + owner: repository.owner, + name: repository.name, + url: repository.url, + }, + automation: { + branches: true, + pullRequests: true, + comments: true, + }, + installation: { + status: "pending", + id: null, + }, + connectedAt: new Date().toISOString(), + }; + + await context.stateStore.setRepositoryConnection(linkedProject.linkedProjectId, connection); + + return { + command: "project.connect-repo", + result: { + linkedProjectId: linkedProject.linkedProjectId, + workspace: authState.workspace, + project: linkedProject.project, + repositoryConnection: connection, + }, + warnings: [], + nextSteps: ["prisma-cli project show"], + }; +} + +export async function runProjectDisconnectRepo( + context: CommandContext, +): Promise> { + if (isRealMode(context)) { + throw repoConnectionUnavailableError(); + } + + const { authState, linkedProject } = await requireLinkedProjectForRepoCommand(context); + const existingConnection = await context.stateStore.readRepositoryConnection(linkedProject.linkedProjectId); + + await context.stateStore.clearRepositoryConnection(linkedProject.linkedProjectId); + + return { + command: "project.disconnect-repo", + result: { + linkedProjectId: linkedProject.linkedProjectId, + workspace: authState.workspace, + project: linkedProject.project, + repositoryConnection: existingConnection, + }, + warnings: [], + nextSteps: ["prisma-cli project show"], + }; +} + async function resolveProjectIdForLink( context: CommandContext, authState: AuthStateResult, @@ -339,3 +439,58 @@ function projectSelectionRequiredError() { "project", ); } + +async function requireLinkedProjectForRepoCommand(context: CommandContext): Promise<{ + authState: AuthStateResult & { workspace: NonNullable }; + linkedProject: ProjectShowResult & { + linkedProjectId: string; + project: NonNullable; + }; +}> { + const authState = await requireAuthenticatedAuthState(context); + if (!authState.workspace) { + throw authRequiredError(); + } + + const gateways = createCliUseCaseGateways(context); + const projectUseCases = createProjectUseCases(gateways); + const linkedProject = await projectUseCases.show(authState); + + if (!linkedProject.linkedProjectId) { + throw new CliError({ + code: "PROJECT_NOT_LINKED", + domain: "project", + summary: "Project link required", + why: "Repository connection needs a linked project for the current repo.", + fix: "Run prisma-cli project link before connecting a GitHub repository.", + exitCode: 1, + nextSteps: ["prisma-cli project link"], + }); + } + + if (!linkedProject.project) { + throw projectNotFoundError( + `The linked project "${linkedProject.linkedProjectId}" does not exist in workspace "${authState.workspace.name}".`, + "Run prisma-cli project list and relink the repo to an accessible project.", + ["prisma-cli project list", "prisma-cli project link"], + ); + } + + return { + authState: authState as AuthStateResult & { workspace: NonNullable }, + linkedProject: linkedProject as ProjectShowResult & { + linkedProjectId: string; + project: NonNullable; + }, + }; +} + +function repoConnectionUnavailableError(): CliError { + return featureUnavailableError( + "Repository connection commands are not available in real provider mode", + "The current Management API does not expose project repository connection resources yet.", + "Use fixture mode to validate the command language, or connect repositories through the platform once the API is available.", + ["prisma-cli project link"], + "project", + ); +} diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index ddd093e..c9321b8 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -1,6 +1,6 @@ import type { CommandDescriptor } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; -import type { ProjectListResult, ProjectShowResult } from "../types/project"; +import type { ProjectListResult, ProjectRepositoryConnectionResult, ProjectShowResult } from "../types/project"; import { renderList, renderMutate, renderShow, serializeList } from "../output/patterns"; export function renderProjectList( @@ -109,3 +109,77 @@ export function renderProjectLink( context.ui, ); } + +export function renderProjectConnectRepo( + context: CommandContext, + descriptor: CommandDescriptor, + result: ProjectRepositoryConnectionResult, +): string[] { + const connection = requireRepositoryConnection(result); + + return renderMutate( + { + title: "Connecting the linked project to a GitHub repository.", + descriptor, + context: [ + { key: "project", value: result.project?.name ?? result.linkedProjectId ?? "not linked" }, + ...(result.workspace ? [{ key: "workspace", value: result.workspace.name }] : []), + { key: "repository", value: `${connection.repository.owner}/${connection.repository.name}` }, + { key: "provider", value: "GitHub" }, + ], + operationDescription: "Applying repository connection", + operationCount: 1, + details: ["Branch automation is now pending GitHub App installation."], + }, + context.ui, + ); +} + +export function renderProjectDisconnectRepo( + context: CommandContext, + descriptor: CommandDescriptor, + result: ProjectRepositoryConnectionResult, +): string[] { + const connection = result.repositoryConnection; + + return renderMutate( + { + title: "Disconnecting the GitHub repository from the linked project.", + descriptor, + context: [ + { key: "project", value: result.project?.name ?? result.linkedProjectId ?? "not linked" }, + ...(result.workspace ? [{ key: "workspace", value: result.workspace.name }] : []), + { + key: "repository", + value: connection ? `${connection.repository.owner}/${connection.repository.name}` : "not connected", + tone: connection ? "default" : "dim", + }, + ], + operationDescription: "Applying repository disconnection", + operationCount: connection ? 1 : 0, + details: [ + connection + ? "Branch automation is no longer active for this project." + : "No repository connection was configured for this project.", + ], + }, + context.ui, + ); +} + +export function serializeProjectRepositoryConnection(result: ProjectRepositoryConnectionResult) { + return { + linkedProjectId: result.linkedProjectId, + workspace: result.workspace, + project: result.project, + repositoryConnection: result.repositoryConnection, + }; +} + +function requireRepositoryConnection(result: ProjectRepositoryConnectionResult) { + if (!result.repositoryConnection) { + throw new Error("Repository connection result must include a connection for human output."); + } + + return result.repositoryConnection; +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 6fc47d4..a24237d 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -48,7 +48,8 @@ const DESCRIPTORS: CommandDescriptor[] = [ id: "project", path: ["prisma", "project"], description: "Manage the link between this directory and a Prisma project", - examples: ["prisma-cli project list", "prisma-cli project show"], + docsPath: "docs/product/command-spec.md#prisma-project-list", + examples: ["prisma-cli project list", "prisma-cli project connect-repo"], }, { id: "app", @@ -83,6 +84,23 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "Link this directory to a Prisma project", examples: ["prisma-cli project link", "prisma-cli project link proj_123"], }, + { + id: "project.connect-repo", + path: ["prisma", "project", "connect-repo"], + description: "Connect the linked project to a GitHub repository", + docsPath: "docs/product/command-spec.md#prisma-cli-project-connect-repo-git-url", + examples: [ + "prisma-cli project connect-repo", + "prisma-cli project connect-repo git@github.com:prisma/prisma-cli.git", + ], + }, + { + id: "project.disconnect-repo", + path: ["prisma", "project", "disconnect-repo"], + description: "Disconnect the GitHub repository from the linked project", + docsPath: "docs/product/command-spec.md#prisma-cli-project-disconnect-repo", + examples: ["prisma-cli project disconnect-repo"], + }, { id: "branch.list", path: ["prisma", "branch", "list"], diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index 4dc2313..298370c 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -16,3 +16,26 @@ export interface ProjectShowResult { workspace: AuthWorkspace | null; project: ProjectSummary | null; } + +export interface GitRepositoryConnection { + provider: "github"; + repository: { + owner: string; + name: string; + url: string; + }; + automation: { + branches: boolean; + pullRequests: boolean; + comments: boolean; + }; + installation: { + status: "pending" | "connected"; + id: string | null; + }; + connectedAt: string; +} + +export interface ProjectRepositoryConnectionResult extends ProjectShowResult { + repositoryConnection: GitRepositoryConnection | null; +} diff --git a/packages/cli/tests/git-adapter.test.ts b/packages/cli/tests/git-adapter.test.ts new file mode 100644 index 0000000..bce9ea9 --- /dev/null +++ b/packages/cli/tests/git-adapter.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { parseGitHubRepositoryUrl } from "../src/adapters/git"; + +describe("git adapter", () => { + it("parses supported GitHub repository URLs", () => { + expect(parseGitHubRepositoryUrl("https://github.com/prisma/prisma-cli")).toEqual({ + provider: "github", + owner: "prisma", + name: "prisma-cli", + url: "https://github.com/prisma/prisma-cli", + }); + expect(parseGitHubRepositoryUrl("https://github.com/prisma/prisma-cli.git")).toEqual({ + provider: "github", + owner: "prisma", + name: "prisma-cli", + url: "https://github.com/prisma/prisma-cli", + }); + expect(parseGitHubRepositoryUrl("git@github.com:prisma/prisma-cli.git")).toEqual({ + provider: "github", + owner: "prisma", + name: "prisma-cli", + url: "https://github.com/prisma/prisma-cli", + }); + expect(parseGitHubRepositoryUrl("ssh://git@github.com/prisma/prisma-cli.git")).toEqual({ + provider: "github", + owner: "prisma", + name: "prisma-cli", + url: "https://github.com/prisma/prisma-cli", + }); + }); + + it("rejects unsupported providers and non-repository GitHub URLs", () => { + expect(parseGitHubRepositoryUrl("https://gitlab.com/prisma/prisma-cli")).toBeNull(); + expect(parseGitHubRepositoryUrl("https://github.com/prisma/prisma-cli/issues")).toBeNull(); + expect(parseGitHubRepositoryUrl("not a url")).toBeNull(); + }); +}); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 60807b9..18b129c 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -449,6 +449,205 @@ describe("project commands", () => { expect(state.branch.active).toBe("preview"); }); + it("connects a GitHub repository to the linked project in fixture mode", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + await writePrismaConfig(cwd, "proj_123"); + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); + + const result = await executeCli({ + argv: ["project", "connect-repo", "git@github.com:prisma/prisma-cli.git", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + const state = JSON.parse(await readFile(path.join(stateDir, "state.json"), "utf8")); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(payload).toEqual({ + ok: true, + command: "project.connect-repo", + result: { + linkedProjectId: "proj_123", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + repositoryConnection: { + provider: "github", + repository: { + owner: "prisma", + name: "prisma-cli", + url: "https://github.com/prisma/prisma-cli", + }, + automation: { + branches: true, + pullRequests: true, + comments: true, + }, + installation: { + status: "pending", + id: null, + }, + connectedAt: expect.any(String), + }, + }, + warnings: [], + nextSteps: ["prisma-cli project show"], + }); + expect(state.project.repositoryConnectionsByProject.proj_123.repository).toEqual({ + owner: "prisma", + name: "prisma-cli", + url: "https://github.com/prisma/prisma-cli", + }); + }); + + it("renders repository connection in human mode", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + await writePrismaConfig(cwd, "proj_123"); + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); + + const result = await executeCli({ + argv: ["project", "connect-repo", "https://github.com/prisma/prisma-cli"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("project connect-repo → Connecting the linked project to a GitHub repository."); + expect(result.stderr).toContain("│ repository: prisma/prisma-cli"); + expect(result.stderr).toContain("Branch automation is now pending GitHub App installation."); + }); + + it("disconnects the GitHub repository from the linked project", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + await writePrismaConfig(cwd, "proj_123"); + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); + await executeCli({ + argv: ["project", "connect-repo", "https://github.com/prisma/prisma-cli"], + cwd, + stateDir, + fixturePath, + }); + + const result = await executeCli({ + argv: ["project", "disconnect-repo", "--json"], + cwd, + stateDir, + fixturePath, + }); + const state = JSON.parse(await readFile(path.join(stateDir, "state.json"), "utf8")); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + ok: true, + command: "project.disconnect-repo", + result: { + linkedProjectId: "proj_123", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + repositoryConnection: null, + }, + warnings: [], + nextSteps: ["prisma-cli project show"], + }); + expect(state.project.repositoryConnectionsByProject.proj_123).toBeUndefined(); + }); + + it("returns PROJECT_NOT_LINKED for repository connection without a linked project", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); + + const result = await executeCli({ + argv: ["project", "connect-repo", "https://github.com/prisma/prisma-cli", "--json"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + command: "project.connect-repo", + error: { + code: "PROJECT_NOT_LINKED", + domain: "project", + }, + }); + }); + + it("returns REPO_PROVIDER_UNSUPPORTED for non-GitHub repository URLs", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + await writePrismaConfig(cwd, "proj_123"); + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); + + const result = await executeCli({ + argv: ["project", "connect-repo", "https://gitlab.com/prisma/prisma-cli", "--json"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(2); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + command: "project.connect-repo", + error: { + code: "REPO_PROVIDER_UNSUPPORTED", + domain: "project", + }, + }); + }); + it("shows the documented help text for project commands", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -471,6 +670,18 @@ describe("project commands", () => { stateDir, fixturePath, }); + const connectRepoHelp = await executeCli({ + argv: ["project", "connect-repo", "--help"], + cwd, + stateDir, + fixturePath, + }); + const disconnectRepoHelp = await executeCli({ + argv: ["project", "disconnect-repo", "--help"], + cwd, + stateDir, + fixturePath, + }); expect(listHelp.exitCode).toBe(0); expect(listHelp.stderr).toContain("List all projects in your workspace"); @@ -487,5 +698,14 @@ describe("project commands", () => { expect(linkHelp.stderr).toContain("Link this directory to a Prisma project"); expect(linkHelp.stderr).toContain("$ prisma-cli project link"); expect(linkHelp.stderr).toContain("$ prisma-cli project link proj_123"); + + expect(connectRepoHelp.exitCode).toBe(0); + expect(connectRepoHelp.stderr).toContain("Connect the linked project to a GitHub repository"); + expect(connectRepoHelp.stderr).toContain("$ prisma-cli project connect-repo"); + expect(connectRepoHelp.stderr).toContain("$ prisma-cli project connect-repo git@github.com:prisma/prisma-cli.git"); + + expect(disconnectRepoHelp.exitCode).toBe(0); + expect(disconnectRepoHelp.stderr).toContain("Disconnect the GitHub repository from the linked project"); + expect(disconnectRepoHelp.stderr).toContain("$ prisma-cli project disconnect-repo"); }); });