Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 46 additions & 10 deletions packages/cli/cli-v2/src/commands/sdk/generate/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import type { TaskStageLabels } from "../../../ui/TaskStageLabels.js";
import type { Workspace } from "../../../workspace/Workspace.js";
import { WorkspaceBuilder } from "../../../workspace/WorkspaceBuilder.js";
import { command } from "../../_internal/command.js";
import { isGitUrl } from "../utils/gitUrl.js";
import { isGithubPrUrl, isGitUrl, parseGithubPrUrl } from "../utils/gitUrl.js";
import { resolveGithubPrBranch } from "../utils/resolveGithubPrBranch.js";

export declare namespace GenerateCommand {
export interface Args extends GlobalArgs {
Expand Down Expand Up @@ -118,15 +119,22 @@ export class GenerateCommand {
};
}

const targets = this.getTargets({
const targets = await this.getTargets({
workspace: workspaceWithOverrides,
args,
groupName: args.target != null ? undefined : (args.group ?? workspaceWithOverrides.sdks?.defaultGroup)
});

this.validateArgs({ workspace: workspaceWithOverrides, args, targets });
// When --output is a PR URL, force local generation since it produces a self-hosted git output.
const forceLocal = args.output != null && isGithubPrUrl(args.output);

await this.runGeneration({ context, workspace: workspaceWithOverrides, targets, args, forceLocal: false });
this.validateArgs({
workspace: workspaceWithOverrides,
args: { ...args, local: args.local || forceLocal },
targets
});

await this.runGeneration({ context, workspace: workspaceWithOverrides, targets, args, forceLocal });
}

private async handleWithFlags(context: Context, args: GenerateCommand.Args): Promise<void> {
Expand Down Expand Up @@ -170,7 +178,7 @@ export class GenerateCommand {
org,
lang: this.resolveLanguage(target),
resolvedSpec,
output: this.parseTargetOutput({ ...args, output }),
output: await this.parseTargetOutput({ ...args, output }),
targetVersion: args["target-version"]
});

Expand Down Expand Up @@ -430,11 +438,37 @@ export class GenerateCommand {
/**
* Parses the --output argument into an OutputSchema.
*
* - GitHub PR URLs (e.g. https://github.com/owner/repo/pull/123)
* resolve the PR's head branch and produce a push-mode git output.
* - Git URLs (ending in .git, or starting with https://github.com/, https://gitlab.com/, git@)
* produce a self-hosted git output with token from GITHUB_TOKEN or GIT_TOKEN env vars.
* - Anything else is treated as a local path.
*/
private parseTargetOutput(args: GenerateCommand.Args): schemas.OutputObjectSchema {
private async parseTargetOutput(args: GenerateCommand.Args): Promise<schemas.OutputObjectSchema> {
if (args.output != null && isGithubPrUrl(args.output)) {
const token = process.env.GITHUB_TOKEN ?? process.env.GIT_TOKEN;
if (token == null) {
throw new CliError({
message:
`A git token is required when --output is a GitHub PR URL.\n\n` +
` Set GITHUB_TOKEN or GIT_TOKEN:\n` +
` export GITHUB_TOKEN=ghp_xxx\n\n` +
` Or use a local path:\n` +
` --output ./my-sdk`
});
}
const prInfo = parseGithubPrUrl(args.output);
const { branch, uri } = await resolveGithubPrBranch(prInfo, token);
return {
git: {
uri,
token,
mode: "push",
branch
}
};
}

if (args.output != null && isGitUrl(args.output)) {
if (!args.local) {
throw new CliError({
Expand Down Expand Up @@ -466,15 +500,15 @@ export class GenerateCommand {
return { path: args.output };
}

private getTargets({
private async getTargets({
workspace,
args,
groupName
}: {
workspace: Workspace;
args: GenerateCommand.Args;
groupName: string | undefined;
}): Target[] {
}): Promise<Target[]> {
let matched = workspace.sdks != null ? this.filterTargetsByGroup(workspace.sdks.targets, groupName) : [];
if (args.target != null) {
matched = matched.filter((t) => t.name === args.target);
Expand All @@ -488,10 +522,11 @@ export class GenerateCommand {
}
throw new Error("No targets configured in fern.yml");
}
const resolvedOutput = args.output != null ? await this.parseTargetOutput(args) : undefined;
return matched.map((target) => ({
...target,
version: args["target-version"] ?? target.version,
output: args.output != null ? this.parseTargetOutput(args) : target.output
output: resolvedOutput ?? target.output
}));
}

Expand Down Expand Up @@ -655,7 +690,8 @@ export function addGenerateCommand(cli: Argv<GlobalArgs>): void {
})
.option("output", {
type: "string",
description: "Output path or git URL (required with --api; requires --preview in workspace mode)"
description:
"Output path, git URL, or GitHub PR URL (e.g. https://github.com/owner/repo/pull/123 to push to the PR's branch)"
})
.option("output-version", {
type: "string",
Expand Down
30 changes: 28 additions & 2 deletions packages/cli/cli-v2/src/commands/sdk/generate/parseOutputArg.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
import type { schemas } from "@fern-api/config";

import { isGitUrl } from "../utils/gitUrl.js";
import { isGithubPrUrl, isGitUrl, parseGithubPrUrl } from "../utils/gitUrl.js";
import { resolveGithubPrBranch } from "../utils/resolveGithubPrBranch.js";

/**
* Parses the --output argument into an OutputSchema.
*
* - GitHub PR URLs (e.g. https://github.com/owner/repo/pull/123)
* resolve the PR's head branch and produce a push-mode git output.
* - Git URLs (ending in .git, or starting with https://github.com/, https://gitlab.com/, git@)
* produce a self-hosted git output with token from GITHUB_TOKEN or GIT_TOKEN env vars.
* - Anything else is treated as a local path.
*/
export function parseOutputArg(outputArg: string): schemas.OutputObjectSchema {
export async function parseOutputArg(outputArg: string): Promise<schemas.OutputObjectSchema> {
if (isGithubPrUrl(outputArg)) {
const token = process.env.GITHUB_TOKEN ?? process.env.GIT_TOKEN;
if (token == null) {
throw new Error(
`A git token is required when --output is a GitHub PR URL.\n\n` +
` Set GITHUB_TOKEN or GIT_TOKEN:\n` +
` export GITHUB_TOKEN=ghp_xxx\n\n` +
` Or use a local path:\n` +
` --output ./my-sdk`
);
}
const prInfo = parseGithubPrUrl(outputArg);
const { branch, uri } = await resolveGithubPrBranch(prInfo, token);
return {
git: {
uri,
token,
mode: "push",
branch
}
};
}

if (isGitUrl(outputArg)) {
const token = process.env.GITHUB_TOKEN ?? process.env.GIT_TOKEN;
if (token == null) {
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/cli-v2/src/commands/sdk/utils/gitUrl.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
/**
* Returns true if the given value looks like a GitHub pull request URL.
*
* Matches URLs like `https://github.com/owner/repo/pull/123`.
*/
export function isGithubPrUrl(value: string): boolean {
return /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(value);
}

export interface GithubPrUrlInfo {
owner: string;
repo: string;
prNumber: number;
}

/**
* Parses a GitHub pull request URL into its components.
*
* @param url A URL like `https://github.com/owner/repo/pull/123`
*/
export function parseGithubPrUrl(url: string): GithubPrUrlInfo {
const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
if (match == null || match[1] == null || match[2] == null || match[3] == null) {
throw new Error(`Invalid GitHub PR URL: ${url}`);
}
return {
owner: match[1],
repo: match[2],
prNumber: parseInt(match[3], 10)
};
}

/**
* Returns true if the given value looks like a git URL.
*
* Matches URLs ending in `.git`, or starting with `https://github.com/`,
* `https://gitlab.com/`, or `git@`.
*
* Note: GitHub PR URLs (e.g. `https://github.com/owner/repo/pull/123`)
* are excluded — use `isGithubPrUrl` for those.
*/
export function isGitUrl(value: string): boolean {
if (isGithubPrUrl(value)) {
return false;
}
return (
value.endsWith(".git") ||
value.startsWith("https://github.com/") ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { GithubPrUrlInfo } from "./gitUrl.js";

interface GithubPrBranchInfo {
/** The head branch of the PR (e.g. "my-feature-branch") */
branch: string;
/** The repository URI as "owner/repo" */
uri: string;
}

/**
* Fetches the head branch name of a GitHub pull request.
*
* Uses the GitHub REST API. Requires a token with read access to the repository.
*/
export async function resolveGithubPrBranch(pr: GithubPrUrlInfo, token: string): Promise<GithubPrBranchInfo> {
const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "fern-cli"
}
});

if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
`Failed to fetch PR #${pr.prNumber} from ${pr.owner}/${pr.repo}: ${response.status} ${response.statusText}${body ? `\n${body}` : ""}`
);
}

const data = (await response.json()) as { head?: { ref?: string } };
const branch = data.head?.ref;
if (branch == null) {
throw new Error(`Could not determine head branch for PR #${pr.prNumber}`);
}

return {
branch,
uri: `${pr.owner}/${pr.repo}`
};
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
7 changes: 7 additions & 0 deletions packages/cli/cli/changes/unreleased/add-output-pr-url.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json

- summary: |
Support GitHub PR URLs in `--output` flag for `fern generate`. When a PR URL
like `https://github.com/owner/repo/pull/123` is provided, the CLI resolves
the PR's head branch and pushes generated code directly to it.
type: feat
Loading