diff --git a/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts b/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts index 872c0b0f269..d53fccf15b8 100644 --- a/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts +++ b/packages/cli/configuration/src/generators-yml/GeneratorsConfiguration.ts @@ -2,13 +2,14 @@ import { Values } from "@fern-api/core-utils"; import { AbsoluteFilePath } from "@fern-api/fs-utils"; import { FernFiddle } from "@fern-fern/fiddle-sdk"; import { Audiences } from "../commons"; +import { APIDefinitionSettingsSchema } from "./schemas/APIConfigurationSchema"; import { GeneratorsConfigurationSchema } from "./schemas/GeneratorsConfigurationSchema"; import { ReadmeSchema } from "./schemas/ReadmeSchema"; -import { APIDefinitionSettingsSchema } from "./schemas/APIConfigurationSchema"; export interface GeneratorsConfiguration { api?: APIDefinition; defaultGroup: string | undefined; + reviewers: Reviewers | undefined; groups: GeneratorGroup[]; whitelabel: FernFiddle.WhitelabelConfig | undefined; @@ -40,6 +41,16 @@ export interface GeneratorGroup { groupName: string; audiences: Audiences; generators: GeneratorInvocation[]; + reviewers: Reviewers | undefined; +} + +export interface Reviewer { + name: string; +} + +export interface Reviewers { + teams?: Reviewer[] | undefined; + users?: Reviewer[] | undefined; } export interface GeneratorInvocation { @@ -47,6 +58,8 @@ export interface GeneratorInvocation { irVersionOverride: string | undefined; version: string; config: unknown; + // Note this also includes a reviewers block for PR mode, it's from fiddle + // and the same schema outputMode: FernFiddle.remoteGen.OutputMode; absolutePathToLocalOutput: AbsoluteFilePath | undefined; absolutePathToLocalSnippets: AbsoluteFilePath | undefined; diff --git a/packages/cli/configuration/src/generators-yml/__test__/convertGeneratorsConfiguration.test.ts b/packages/cli/configuration/src/generators-yml/__test__/convertGeneratorsConfiguration.test.ts index 2b636af0748..8dc35b5eb1b 100644 --- a/packages/cli/configuration/src/generators-yml/__test__/convertGeneratorsConfiguration.test.ts +++ b/packages/cli/configuration/src/generators-yml/__test__/convertGeneratorsConfiguration.test.ts @@ -205,6 +205,50 @@ describe("convertGeneratorsConfiguration", () => { } }); + it("Reviewers", async () => { + const converted = await convertGeneratorsConfiguration({ + absolutePathToGeneratorsConfiguration: AbsoluteFilePath.of(__filename), + rawGeneratorsConfiguration: { + reviewers: { + teams: [{ name: "fern-eng" }], + users: [{ name: "armando" }] + }, + groups: { + "stage:java": { + generators: [ + { + name: "fernapi/fern-java-sdk", + version: "0.8.8-rc0", + config: { + "package-prefix": "com.test.sdk" + }, + metadata: { + license: "MIT" + }, + github: { + repository: "fern-api/github-app-test", + mode: "pull-request", + reviewers: { + users: [{ name: "deep" }] + } + } + } + ] + } + } + } + }); + const output = converted.groups[0]?.generators[0]?.outputMode; + expect(output?.type).toEqual("githubV2"); + if (output?.type === "githubV2" && output.githubV2.type === "pullRequest") { + expect(output.githubV2.reviewers != null).toBeTruthy(); + expect(output.githubV2.reviewers?.length).toEqual(3); + + const reviewerNames = output.githubV2.reviewers?.map((reviewer) => reviewer.name); + expect(reviewerNames).toEqual(["fern-eng", "armando", "deep"]); + } + }); + it("Output Metadata", async () => { const converted = await convertGeneratorsConfiguration({ absolutePathToGeneratorsConfiguration: AbsoluteFilePath.of(__filename), diff --git a/packages/cli/configuration/src/generators-yml/convertGeneratorsConfiguration.ts b/packages/cli/configuration/src/generators-yml/convertGeneratorsConfiguration.ts index e30b1367d76..173f96e570e 100644 --- a/packages/cli/configuration/src/generators-yml/convertGeneratorsConfiguration.ts +++ b/packages/cli/configuration/src/generators-yml/convertGeneratorsConfiguration.ts @@ -1,7 +1,7 @@ import { assertNever } from "@fern-api/core-utils"; import { AbsoluteFilePath, dirname, join, RelativeFilePath, resolve } from "@fern-api/fs-utils"; import { FernFiddle } from "@fern-fern/fiddle-sdk"; -import { OutputMetadata, PublishingMetadata, PypiMetadata } from "@fern-fern/fiddle-sdk/api"; +import { GithubPullRequestReviewer, OutputMetadata, PublishingMetadata, PypiMetadata } from "@fern-fern/fiddle-sdk/api"; import { readFile } from "fs/promises"; import path from "path"; import { @@ -24,10 +24,12 @@ import { OPENAPI_OVERRIDES_LOCATION_KEY } from "./schemas/GeneratorsConfigurationSchema"; import { GithubLicenseSchema } from "./schemas/GithubLicenseSchema"; +import { GithubPullRequestSchema } from "./schemas/GithubPullRequestSchema"; import { MavenOutputLocationSchema } from "./schemas/MavenOutputLocationSchema"; import { OutputMetadataSchema } from "./schemas/OutputMetadataSchema"; import { PypiOutputMetadataSchema } from "./schemas/PypiOutputMetadataSchema"; import { ReadmeSchema } from "./schemas/ReadmeSchema"; +import { ReviewersSchema } from "./schemas/ReviewersSchema"; export async function convertGeneratorsConfiguration({ absolutePathToGeneratorsConfiguration, @@ -44,6 +46,7 @@ export async function convertGeneratorsConfiguration({ api: parsedApiConfiguration, rawConfiguration: rawGeneratorsConfiguration, defaultGroup: rawGeneratorsConfiguration["default-group"], + reviewers: rawGeneratorsConfiguration.reviewers, groups: rawGeneratorsConfiguration.groups != null ? await Promise.all( @@ -53,6 +56,7 @@ export async function convertGeneratorsConfiguration({ groupName, group, maybeTopLevelMetadata, + maybeTopLevelReviewers: rawGeneratorsConfiguration.reviewers, readme }) ) @@ -175,17 +179,20 @@ async function convertGroup({ groupName, group, maybeTopLevelMetadata, + maybeTopLevelReviewers, readme }: { absolutePathToGeneratorsConfiguration: AbsoluteFilePath; groupName: string; group: GeneratorGroupSchema; maybeTopLevelMetadata: OutputMetadata | undefined; + maybeTopLevelReviewers: ReviewersSchema | undefined; readme: ReadmeSchema | undefined; }): Promise { const maybeGroupLevelMetadata = getOutputMetadata(group.metadata); return { groupName, + reviewers: group.reviewers, audiences: group.audiences == null ? { type: "all" } : { type: "select", audiences: group.audiences }, generators: await Promise.all( group.generators.map((generator) => @@ -194,6 +201,8 @@ async function convertGroup({ generator, maybeTopLevelMetadata, maybeGroupLevelMetadata, + maybeTopLevelReviewers, + maybeGroupLevelReviewers: group.reviewers, readme }) ) @@ -206,12 +215,16 @@ async function convertGenerator({ generator, maybeGroupLevelMetadata, maybeTopLevelMetadata, + maybeGroupLevelReviewers, + maybeTopLevelReviewers, readme }: { absolutePathToGeneratorsConfiguration: AbsoluteFilePath; generator: GeneratorInvocationSchema; maybeGroupLevelMetadata: OutputMetadata | undefined; maybeTopLevelMetadata: OutputMetadata | undefined; + maybeGroupLevelReviewers: ReviewersSchema | undefined; + maybeTopLevelReviewers: ReviewersSchema | undefined; readme: ReadmeSchema | undefined; }): Promise { return { @@ -222,7 +235,9 @@ async function convertGenerator({ absolutePathToGeneratorsConfiguration, generator, maybeGroupLevelMetadata, - maybeTopLevelMetadata + maybeTopLevelMetadata, + maybeGroupLevelReviewers, + maybeTopLevelReviewers }), keywords: generator.keywords, smartCasing: generator["smart-casing"] ?? false, @@ -283,16 +298,63 @@ function _getPypiMetadata({ } return maybePyPiMetadata; } + +function _getReviewers({ + topLevelReviewers, + groupLevelReviewers, + outputModeReviewers +}: { + topLevelReviewers: ReviewersSchema | undefined; + groupLevelReviewers: ReviewersSchema | undefined; + outputModeReviewers: ReviewersSchema | undefined; +}): GithubPullRequestReviewer[] { + const teamNames = new Set(); + const userNames = new Set(); + + const reviewers: GithubPullRequestReviewer[] = []; + + const allTeamReviewers = [ + ...(topLevelReviewers?.teams ?? []), + ...(groupLevelReviewers?.teams ?? []), + ...(outputModeReviewers?.teams ?? []) + ]; + const allUserReviewers = [ + ...(topLevelReviewers?.users ?? []), + ...(groupLevelReviewers?.users ?? []), + ...(outputModeReviewers?.users ?? []) + ]; + + for (const team of allTeamReviewers) { + if (!teamNames.has(team.name)) { + reviewers.push(GithubPullRequestReviewer.team({ name: team.name })); + teamNames.add(team.name); + } + } + + for (const user of allUserReviewers) { + if (!userNames.has(user.name)) { + reviewers.push(GithubPullRequestReviewer.user({ name: user.name })); + userNames.add(user.name); + } + } + + return reviewers; +} + async function convertOutputMode({ absolutePathToGeneratorsConfiguration, generator, maybeGroupLevelMetadata = {}, - maybeTopLevelMetadata = {} + maybeTopLevelMetadata = {}, + maybeGroupLevelReviewers, + maybeTopLevelReviewers }: { absolutePathToGeneratorsConfiguration: AbsoluteFilePath; generator: GeneratorInvocationSchema; maybeGroupLevelMetadata: OutputMetadata | undefined; maybeTopLevelMetadata: OutputMetadata | undefined; + maybeGroupLevelReviewers: ReviewersSchema | undefined; + maybeTopLevelReviewers: ReviewersSchema | undefined; }): Promise { const downloadSnippets = generator.snippets != null && generator.snippets.path !== ""; if (generator.github != null) { @@ -324,16 +386,23 @@ async function convertOutputMode({ downloadSnippets }) ); - case "pull-request": + case "pull-request": { + const reviewers = _getReviewers({ + topLevelReviewers: maybeTopLevelReviewers, + groupLevelReviewers: maybeGroupLevelReviewers, + outputModeReviewers: (generator.github as GithubPullRequestSchema).reviewers + }); return FernFiddle.OutputMode.githubV2( FernFiddle.GithubOutputModeV2.pullRequest({ owner, repo, license, publishInfo, - downloadSnippets + downloadSnippets, + reviewers }) ); + } case "push": return FernFiddle.OutputMode.githubV2( FernFiddle.GithubOutputModeV2.push({ diff --git a/packages/cli/configuration/src/generators-yml/schemas/GeneratorGroupSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/GeneratorGroupSchema.ts index fe640e12453..10e5f47356d 100644 --- a/packages/cli/configuration/src/generators-yml/schemas/GeneratorGroupSchema.ts +++ b/packages/cli/configuration/src/generators-yml/schemas/GeneratorGroupSchema.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import { GeneratorInvocationSchema } from "./GeneratorInvocationSchema"; import { OutputMetadataSchema } from "./OutputMetadataSchema"; +import { ReviewersSchema, REVIEWERS_KEY } from "./ReviewersSchema"; export const GeneratorGroupSchema = z.strictObject({ audiences: z.optional(z.array(z.string())), generators: z.array(GeneratorInvocationSchema), - metadata: z.optional(OutputMetadataSchema) + metadata: z.optional(OutputMetadataSchema), + [REVIEWERS_KEY]: z.optional(ReviewersSchema) }); export type GeneratorGroupSchema = z.infer; diff --git a/packages/cli/configuration/src/generators-yml/schemas/GeneratorsConfigurationSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/GeneratorsConfigurationSchema.ts index fd476e904c5..961b6281e0f 100644 --- a/packages/cli/configuration/src/generators-yml/schemas/GeneratorsConfigurationSchema.ts +++ b/packages/cli/configuration/src/generators-yml/schemas/GeneratorsConfigurationSchema.ts @@ -4,6 +4,7 @@ import { GeneratorGroupSchema } from "./GeneratorGroupSchema"; import { GeneratorsOpenAPISchema } from "./GeneratorsOpenAPISchema"; import { OutputMetadataSchema } from "./OutputMetadataSchema"; import { ReadmeSchema } from "./ReadmeSchema"; +import { ReviewersSchema, REVIEWERS_KEY } from "./ReviewersSchema"; import { WhitelabelConfigurationSchema } from "./WhitelabelConfigurationSchema"; export const DEFAULT_GROUP_GENERATORS_CONFIG_KEY = "default-group"; @@ -25,6 +26,8 @@ export const GeneratorsConfigurationSchema = z.strictObject({ [DEFAULT_GROUP_GENERATORS_CONFIG_KEY]: z.optional(z.string()), groups: z.optional(z.record(GeneratorGroupSchema)), + [REVIEWERS_KEY]: z.optional(ReviewersSchema), + // deprecated, use the `api` key instead [OPENAPI_LOCATION_KEY]: z.optional(GeneratorsOpenAPISchema), [OPENAPI_OVERRIDES_LOCATION_KEY]: z.optional(z.string()), diff --git a/packages/cli/configuration/src/generators-yml/schemas/GithubPullRequestSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/GithubPullRequestSchema.ts index adcea732a72..608385cb307 100644 --- a/packages/cli/configuration/src/generators-yml/schemas/GithubPullRequestSchema.ts +++ b/packages/cli/configuration/src/generators-yml/schemas/GithubPullRequestSchema.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import { GithubLicenseSchema } from "./GithubLicenseSchema"; +import { ReviewersSchema, REVIEWERS_KEY } from "./ReviewersSchema"; export const GithubPullRequestSchema = z.strictObject({ repository: z.string(), branch: z.optional(z.string()), license: z.optional(GithubLicenseSchema), - mode: z.literal("pull-request") + mode: z.literal("pull-request"), + [REVIEWERS_KEY]: z.optional(ReviewersSchema) }); export type GithubPullRequestSchema = z.infer; diff --git a/packages/cli/configuration/src/generators-yml/schemas/ReviewersSchema.ts b/packages/cli/configuration/src/generators-yml/schemas/ReviewersSchema.ts new file mode 100644 index 00000000000..7829f779041 --- /dev/null +++ b/packages/cli/configuration/src/generators-yml/schemas/ReviewersSchema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const REVIEWERS_KEY = "reviewers"; + +// Overkill object right now, but at some point we might +// need to specify orgs, or other things +const ReviewerSchema = z.strictObject({ + name: z.string() +}); + +export const ReviewersSchema = z.strictObject({ + teams: z.optional(z.array(ReviewerSchema)), + users: z.optional(z.array(ReviewerSchema)) +}); + +export type ReviewersSchema = z.infer; diff --git a/packages/seed/src/commands/test/test-runner/DockerTestRunner.ts b/packages/seed/src/commands/test/test-runner/DockerTestRunner.ts index cced473c8a0..f8c4003526f 100644 --- a/packages/seed/src/commands/test/test-runner/DockerTestRunner.ts +++ b/packages/seed/src/commands/test/test-runner/DockerTestRunner.ts @@ -46,6 +46,7 @@ export class DockerTestRunner extends TestRunner { }: TestRunner.DoRunArgs): Promise { const generatorGroup: generatorsYml.GeneratorGroup = { groupName: "test", + reviewers: undefined, audiences: selectAudiences != null ? { type: "select", audiences: selectAudiences } : ALL_AUDIENCES, generators: [ getGeneratorInvocation({