diff --git a/.changeset/beige-cycles-act.md b/.changeset/beige-cycles-act.md new file mode 100644 index 000000000..906221294 --- /dev/null +++ b/.changeset/beige-cycles-act.md @@ -0,0 +1,7 @@ +--- +'@graphql-hive/cli': minor +--- + +Support forwarding GitHub repository information for schema checks and schema publishes when using the `--github` flag. + +Please upgrade if you want to correctly forward the information for (federated) subgraphs to the Hive registry. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index bc429c56c..83f7b173d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -73,7 +73,7 @@ We recommend the following flow if you are having issues with running Hive local 3. Create a token from that target 4. Go to `packages/libraries/cli` and run `pnpm build` 5. Inside `packages/libraries/cli`, run: - `pnpm start schema:publish --token "YOUR_TOKEN_HERE" --registry "http://localhost:4000/graphql" examples/single.graphql` + `pnpm start schema:publish --registry.accessToken "YOUR_TOKEN_HERE" --registry.endpoint "http://localhost:4000/graphql" examples/single.graphql` ### Setting up Slack App for developing diff --git a/packages/libraries/cli/src/commands/schema/check.ts b/packages/libraries/cli/src/commands/schema/check.ts index 05075c464..7d1ee1561 100644 --- a/packages/libraries/cli/src/commands/schema/check.ts +++ b/packages/libraries/cli/src/commands/schema/check.ts @@ -10,7 +10,6 @@ import { renderErrors, renderWarnings, } from '../../helpers/schema'; -import { invariant } from '../../helpers/validation'; const schemaCheckMutation = graphql(/* GraphQL */ ` mutation schemaCheck($input: SchemaCheckInput!, $usesGitHubApp: Boolean!) { @@ -166,24 +165,36 @@ export default class SchemaCheck extends Command { const commit = flags.commit || git?.commit; const author = flags.author || git?.author; - invariant(typeof sdl === 'string' && sdl.length > 0, 'Schema seems empty'); + if (typeof sdl !== 'string' || sdl.length === 0) { + throw new Errors.CLIError('Schema seems empty'); + } + + let github: null | { + commit: string; + repository: string | null; + } = null; if (usesGitHubApp) { - invariant( - typeof commit === 'string', - `Couldn't resolve commit sha required for GitHub Application`, - ); + if (!commit) { + throw new Errors.CLIError(`Couldn't resolve commit sha required for GitHub Application`); + } + // eslint-disable-next-line no-process-env + const repository = process.env['GITHUB_REPOSITORY'] ?? null; + if (!repository) { + throw new Errors.CLIError(`Missing "GITHUB_REPOSITORY" environment variable.`); + } + + github = { + commit: commit, + repository, + }; } const result = await this.registryApi(endpoint, accessToken).request(schemaCheckMutation, { input: { service, sdl: minifySchema(sdl), - github: usesGitHubApp - ? { - commit: commit!, - } - : null, + github, meta: !!commit && !!author ? { diff --git a/packages/libraries/cli/src/commands/schema/publish.ts b/packages/libraries/cli/src/commands/schema/publish.ts index 9c5d12c1f..786c56c1a 100644 --- a/packages/libraries/cli/src/commands/schema/publish.ts +++ b/packages/libraries/cli/src/commands/schema/publish.ts @@ -206,6 +206,11 @@ export default class SchemaPublish extends Command { env: 'HIVE_AUTHOR', }); + let gitHub: null | { + repository: string; + commit: string; + } = null; + if (!commit || !author) { const git = await gitInfo(() => { this.warn(`No git information found. Couldn't resolve author and commit.`); @@ -228,6 +233,18 @@ export default class SchemaPublish extends Command { throw new Errors.CLIError(`Missing "commit"`); } + if (usesGitHubApp) { + // eslint-disable-next-line no-process-env + const repository = process.env['GITHUB_REPOSITORY'] ?? null; + if (!repository) { + throw new Errors.CLIError(`Missing "GITHUB_REPOSITORY" environment variable.`); + } + gitHub = { + repository, + commit, + }; + } + let sdl: string; try { const rawSdl = await loadSchema(file); @@ -255,9 +272,9 @@ export default class SchemaPublish extends Command { force, experimental_acceptBreakingChanges: experimental_acceptBreakingChanges === true, metadata, - github: usesGitHubApp, + gitHub, }, - usesGitHubApp, + usesGitHubApp: !!gitHub, }); if (result.schemaPublish.__typename === 'SchemaPublishSuccess') { diff --git a/packages/migrations/src/actions/2023.10.05T11.44.36.schema-checks-github-repository.ts b/packages/migrations/src/actions/2023.10.05T11.44.36.schema-checks-github-repository.ts new file mode 100644 index 000000000..f5f289feb --- /dev/null +++ b/packages/migrations/src/actions/2023.10.05T11.44.36.schema-checks-github-repository.ts @@ -0,0 +1,16 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2023.08.03T11.44.36.schema-checks-github-repository.ts', + run: ({ sql }) => sql` + ALTER TABLE "public"."schema_checks" + ADD COLUMN "github_repository" text + , ADD COLUMN "github_sha" text + ; + + ALTER TABLE "public"."schema_versions" + ADD COLUMN "github_repository" text + , ADD COLUMN "github_sha" text + ; + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 67085d5e9..0388b908e 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -53,6 +53,7 @@ import migration_2023_08_01T11_44_36_schema_checks_expires_at from './actions/20 import migration_2023_09_01T09_54_00_zendesk_support from './actions/2023.09.01T09.54.00.zendesk-support'; import migration_2023_09_25T15_23_00_github_check_with_project_name from './actions/2023.09.25T15.23.00.github-check-with-project-name'; import migration_2023_09_28T14_14_14_native_fed_v2 from './actions/2023.09.28T14.14.14.native-fed-v2'; +import migration_2023_10_05T11_44_36_schema_checks_github_repository from './actions/2023.10.05T11.44.36.schema-checks-github-repository'; import { runMigrations } from './pg-migrator'; export const runPGMigrations = (args: { slonik: DatabasePool; runTo?: string }) => @@ -114,5 +115,6 @@ export const runPGMigrations = (args: { slonik: DatabasePool; runTo?: string }) migration_2023_09_01T09_54_00_zendesk_support, migration_2023_09_25T15_23_00_github_check_with_project_name, migration_2023_09_28T14_14_14_native_fed_v2, + migration_2023_10_05T11_44_36_schema_checks_github_repository, ], }); diff --git a/packages/services/api/src/modules/alerts/providers/adapters/common.ts b/packages/services/api/src/modules/alerts/providers/adapters/common.ts index cf9a4f7db..2287195ed 100644 --- a/packages/services/api/src/modules/alerts/providers/adapters/common.ts +++ b/packages/services/api/src/modules/alerts/providers/adapters/common.ts @@ -1,20 +1,17 @@ import { Change, CriticalityLevel } from '@graphql-inspector/core'; import type * as Types from '../../../../__generated__/types'; -import { - Alert, - AlertChannel, - Organization, - Project, - SchemaVersion, - Target, -} from '../../../../shared/entities'; +import { Alert, AlertChannel, Organization, Project, Target } from '../../../../shared/entities'; export interface SchemaChangeNotificationInput { event: { organization: Pick; project: Pick; target: Pick; - schema: Pick; + schema: { + id: string; + commit: string; + valid: boolean; + }; changes: Array; messages: string[]; errors: Types.SchemaError[]; diff --git a/packages/services/api/src/modules/integrations/module.graphql.ts b/packages/services/api/src/modules/integrations/module.graphql.ts index bf1fdba00..03a171ca3 100644 --- a/packages/services/api/src/modules/integrations/module.graphql.ts +++ b/packages/services/api/src/modules/integrations/module.graphql.ts @@ -47,7 +47,6 @@ export default gql` } extend type Project { - gitRepository: String isProjectNameInGitHubCheckEnabled: Boolean! } `; diff --git a/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts b/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts index 863fb8678..7fe540e29 100644 --- a/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts +++ b/packages/services/api/src/modules/integrations/providers/github-integration-manager.ts @@ -136,6 +136,22 @@ export class GitHubIntegrationManager { }); } + /** + * Check whether the given organization has access to a given GitHub repository. + */ + async hasAccessToGitHubRepository(props: { + selector: OrganizationSelector; + repositoryName: `${string}/${string}`; + }): Promise { + const repositories = await this.getRepositories(props.selector); + + if (!repositories) { + return false; + } + + return repositories.some(repo => repo.nameWithOwner === props.repositoryName); + } + async getOrganization(selector: { installation: string }) { const organization = await this.storage.getOrganizationByGitHubInstallationId({ installationId: selector.installation, diff --git a/packages/services/api/src/modules/project/module.graphql.ts b/packages/services/api/src/modules/project/module.graphql.ts index 4d4038423..602facc40 100644 --- a/packages/services/api/src/modules/project/module.graphql.ts +++ b/packages/services/api/src/modules/project/module.graphql.ts @@ -9,9 +9,6 @@ export default gql` extend type Mutation { createProject(input: CreateProjectInput!): CreateProjectResult! updateProjectName(input: UpdateProjectNameInput!): UpdateProjectNameResult! - updateProjectGitRepository( - input: UpdateProjectGitRepositoryInput! - ): UpdateProjectGitRepositoryResult! deleteProject(selector: ProjectSelectorInput!): DeleteProjectPayload! } diff --git a/packages/services/api/src/modules/project/providers/project-manager.ts b/packages/services/api/src/modules/project/providers/project-manager.ts index 153acc2f4..4b6d23e8e 100644 --- a/packages/services/api/src/modules/project/providers/project-manager.ts +++ b/packages/services/api/src/modules/project/providers/project-manager.ts @@ -171,23 +171,4 @@ export class ProjectManager { return result; } - - async updateGitRepository( - input: { - gitRepository?: string | null; - } & ProjectSelector, - ): Promise { - const { gitRepository, organization, project } = input; - this.logger.info('Updating a project git repository (input=%o)', input); - await this.authManager.ensureProjectAccess({ - ...input, - scope: ProjectAccessScope.SETTINGS, - }); - - return this.storage.updateProjectGitRepository({ - gitRepository: gitRepository?.trim() === '' ? null : gitRepository, - organization, - project, - }); - } } diff --git a/packages/services/api/src/modules/project/resolvers.ts b/packages/services/api/src/modules/project/resolvers.ts index e0c2e1aef..00f2fb75b 100644 --- a/packages/services/api/src/modules/project/resolvers.ts +++ b/packages/services/api/src/modules/project/resolvers.ts @@ -9,10 +9,6 @@ import { ProjectManager } from './providers/project-manager'; const ProjectNameModel = z.string().min(2).max(40); const URLModel = z.string().url().max(500); -const RepoOwnerWithNameModel = z - .string() - .regex(/^[^/]+\/[^/]+$/, 'Expected owner/name format') - .max(200); const MaybeModel = (value: T) => z.union([z.null(), z.undefined(), value]); export const resolvers: ProjectModule.Resolvers & { ProjectType: any } = { @@ -155,41 +151,6 @@ export const resolvers: ProjectModule.Resolvers & { ProjectType: any } = { }, }; }, - async updateProjectGitRepository(_, { input }, { injector }) { - const UpdateProjectGitRepositoryModel = z.object({ - gitRepository: MaybeModel(RepoOwnerWithNameModel), - }); - - const result = UpdateProjectGitRepositoryModel.safeParse(input); - - if (!result.success) { - return { - error: { - message: - result.error.formErrors.fieldErrors.gitRepository?.[0] ?? 'Please check your input.', - }, - }; - } - - const [organization, project] = await Promise.all([ - injector.get(IdTranslator).translateOrganizationId(input), - injector.get(IdTranslator).translateProjectId(input), - ]); - - return { - ok: { - selector: { - organization: input.organization, - project: input.project, - }, - updatedProject: await injector.get(ProjectManager).updateGitRepository({ - project, - organization, - gitRepository: input.gitRepository, - }), - }, - }; - }, }, ProjectType: { FEDERATION: ProjectType.FEDERATION, diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index 3ad27e8a9..b16465600 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -228,6 +228,17 @@ export default gql` | GitHubSchemaPublishSuccess | GitHubSchemaPublishError + input SchemaPublishGitHubInput { + """ + The repository name. + """ + repository: String! + """ + The commit sha. + """ + commit: String! + } + input SchemaPublishInput { service: ID url: String @@ -244,7 +255,11 @@ export default gql` """ Talk to GitHub Application and create a check-run """ - github: Boolean + github: Boolean @deprecated(reason: "Use SchemaPublishInput.gitHub instead.") + """ + Link GitHub version to a GitHub commit on a repository. + """ + gitHub: SchemaPublishGitHubInput } union SchemaCheckPayload = @@ -380,6 +395,10 @@ export default gql` input GitHubSchemaCheckInput { commit: String! + """ + The repository name of the schema check. + """ + repository: String } input SchemaCompareInput { @@ -475,6 +494,15 @@ export default gql` """ explorer(usage: SchemaExplorerUsageInput): SchemaExplorer! errors: SchemaErrorConnection! + """ + GitHub metadata associated with the schema version. + """ + githubMetadata: SchemaVersionGithubMetadata + } + + type SchemaVersionGithubMetadata { + repository: String! + commit: String! } type SchemaVersionConnection { diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index c70a89743..c55063c1a 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -330,6 +330,10 @@ export class SchemaManager { actionFn(): Promise; changes: Array; previousSchemaVersion: string | null; + github: null | { + repository: string; + sha: string; + }; } & TargetSelector) & ( | { @@ -740,13 +744,14 @@ export class SchemaManager { organization: args.organizationId, project: args.projectId, }); - if (!project.gitRepository) { + const gitRepository = schemaCheck.githubRepository ?? project.gitRepository; + if (!gitRepository) { this.logger.debug( - 'Skip updating GitHub schema check. Project has no git repository connected. (args=%o).', + 'Skip updating GitHub schema check. Schema check has no git repository or project has no git repository connected. (args=%o).', args, ); } else { - const [owner, repository] = project.gitRepository.split('/'); + const [owner, repository] = gitRepository.split('/'); const result = await this.githubIntegrationManager.updateCheckRunToSuccess({ organizationId: args.organizationId, checkRun: { @@ -867,6 +872,41 @@ export class SchemaManager { ); return true; } + + async getGitHubMetadata(schemaVersion: SchemaVersion): Promise { + if (schemaVersion.github) { + return { + repository: schemaVersion.github.repository as `${string}/${string}`, + commit: schemaVersion.github.sha, + }; + } + + const log = await this.getSchemaLog({ + commit: schemaVersion.actionId, + organization: schemaVersion.organization, + project: schemaVersion.project, + target: schemaVersion.target, + }); + + if ('commit' in log && log.commit) { + const project = await this.storage.getProject({ + organization: schemaVersion.organization, + project: schemaVersion.project, + }); + + if (project.gitRepository) { + return { + repository: project.gitRepository, + commit: log.commit, + }; + } + } + + return null; + } } /** diff --git a/packages/services/api/src/modules/schema/providers/schema-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index 3f32c8494..75ecbee1f 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -9,6 +9,7 @@ import type { Span } from '@sentry/types'; import * as Types from '../../../__generated__/types'; import { Organization, Project, ProjectType, Schema, Target } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; +import { isGitHubRepositoryString } from '../../../shared/is-github-repository-string'; import { bolderize } from '../../../shared/markdown'; import { sentry } from '../../../shared/sentry'; import { AlertsManager } from '../../alerts/providers/alerts-manager'; @@ -208,6 +209,56 @@ export class SchemaPublisher { projectType: project.type, }); + let github: null | { + repository: `${string}/${string}`; + sha: string; + } = null; + + if (input.github) { + if (input.github.repository) { + if (!isGitHubRepositoryString(input.github.repository)) { + return { + __typename: 'GitHubSchemaCheckError' as const, + message: 'Invalid github repository name provided.', + }; + } + github = { + repository: input.github.repository, + sha: input.github.commit, + }; + } else if (project.gitRepository == null) { + return { + __typename: 'GitHubSchemaCheckError' as const, + message: 'Git repository is not configured for this project', + }; + } else { + github = { + repository: project.gitRepository, + sha: input.github.commit, + }; + } + } + + if (github != null) { + // Verify that the GitHub repository can be accessed by the user + const hasAccessToGitHubRepository = + await this.gitHubIntegrationManager.hasAccessToGitHubRepository({ + selector: { + organization: organization.id, + }, + repositoryName: github.repository, + }); + + if (!hasAccessToGitHubRepository) { + return { + __typename: 'GitHubSchemaCheckError' as const, + message: + `Missing permissions for updating check-runs on GitHub repository '${github.repository}'. ` + + 'Please make sure that the GitHub App has access on the repository.', + }; + } + } + await this.schemaManager.completeGetStartedCheck({ organization: project.orgId, step: 'checkingSchema', @@ -331,6 +382,8 @@ export class SchemaPublisher { isManuallyApproved: false, manualApprovalUserId: null, githubCheckRunId: null, + githubRepository: github?.repository ?? null, + githubSha: github?.sha ?? null, expiresAt, }); } @@ -395,20 +448,21 @@ export class SchemaPublisher { isManuallyApproved: false, manualApprovalUserId: null, githubCheckRunId: null, + githubRepository: github?.repository ?? null, + githubSha: github?.sha ?? null, expiresAt, }); } - if (input.github) { - let result: Awaited>; + if (github) { + let result: Awaited>; if (checkResult.conclusion === SchemaCheckConclusion.Success) { - result = await this.githubCheck({ + result = await this.githubSchemaCheck({ project, target, organization, serviceName: input.service ?? null, - sha: input.github.commit, conclusion: checkResult.conclusion, changes: checkResult.state?.schemaChanges ?? null, warnings: checkResult.state?.schemaPolicyWarnings ?? null, @@ -416,14 +470,14 @@ export class SchemaPublisher { compositionErrors: null, errors: null, schemaCheckId: schemaCheck?.id ?? null, + github, }); } else { - result = await this.githubCheck({ + result = await this.githubSchemaCheck({ project, target, organization, serviceName: input.service ?? null, - sha: input.github.commit, conclusion: checkResult.conclusion, changes: [ ...(checkResult.state.schemaChanges?.breaking ?? []), @@ -434,6 +488,7 @@ export class SchemaPublisher { warnings: checkResult.state.schemaPolicy?.warnings ?? [], errors: checkResult.state.schemaPolicy?.errors?.map(formatPolicyError) ?? [], schemaCheckId: schemaCheck?.id ?? null, + github, }); } @@ -517,7 +572,7 @@ export class SchemaPublisher { public async updateVersionStatus(input: TargetSelector & { version: string; valid: boolean }) { const updateResult = await this.schemaManager.updateSchemaVersionStatus(input); - if (updateResult.valid === true) { + if (updateResult.isComposable === true) { // Now, when fetching the latest valid version, we should be able to detect // if it's the version we just updated or not. // Why? @@ -810,6 +865,52 @@ export class SchemaPublisher { projectType: project.type, }); + let github: null | { + repository: `${string}/${string}`; + sha: string; + } = null; + + if (input.gitHub != null) { + if (!isGitHubRepositoryString(input.gitHub.repository)) { + return { + __typename: 'GitHubSchemaPublishError' as const, + message: 'Invalid github repository name provided.', + } as const; + } + const hasAccessToGitHubRepository = + await this.gitHubIntegrationManager.hasAccessToGitHubRepository({ + selector: { + organization: organization.id, + }, + repositoryName: input.gitHub.repository, + }); + + if (!hasAccessToGitHubRepository) { + return { + __typename: 'GitHubSchemaPublishError', + message: + `Missing permissions for updating check-runs on GitHub repository '${input.gitHub.repository}'. ` + + 'Please make sure that the GitHub App has access on the repository.', + } as const; + } + + github = { + repository: input.gitHub.repository, + sha: input.gitHub.commit, + }; + } else if (input.github != null) { + if (!project.gitRepository) { + return { + __typename: 'GitHubSchemaPublishError', + message: 'Git repository is not configured for this project.', + } as const; + } + github = { + repository: project.gitRepository, + sha: input.commit, + }; + } + await this.schemaManager.completeGetStartedCheck({ organization: project.orgId, step: 'publishingSchema', @@ -889,18 +990,6 @@ export class SchemaPublisher { conclusion: 'ignored', }); - if (input.github) { - return this.createPublishCheckRun({ - force: false, - initial: false, - input, - project, - valid: true, - changes: [], - errors: [], - }); - } - const linkToWebsite = typeof this.schemaModuleConfig.schemaPublishLink === 'function' ? this.schemaModuleConfig.schemaPublishLink({ @@ -917,8 +1006,22 @@ export class SchemaPublisher { }) : null; + if (github) { + return this.createPublishCheckRun({ + force: false, + initial: false, + valid: true, + changes: [], + errors: [], + + organizationId: organization.id, + github, + detailsUrl: linkToWebsite, + }); + } + return { - __typename: 'SchemaPublishSuccess' as const, + __typename: 'SchemaPublishSuccess', initial: false, valid: true, changes: [], @@ -1028,6 +1131,7 @@ export class SchemaPublisher { base_schema: baseSchema, metadata: input.metadata ?? null, projectType: project.type, + github, actionFn: async () => { if (composable && fullSchemaSdl) { await this.publishToCDN({ @@ -1063,7 +1167,11 @@ export class SchemaPublisher { organization, project, target, - schema: schemaVersion, + schema: { + id: schemaVersion.id, + commit: schemaVersion.actionId, + valid: schemaVersion.isComposable, + }, changes, messages, errors, @@ -1094,16 +1202,17 @@ export class SchemaPublisher { }) : null; - if (input.github) { + if (github) { return this.createPublishCheckRun({ force: false, initial: publishResult.state.initial, - input, - project, valid: publishResult.state.composable, changes: publishResult.state.changes ?? [], errors, messages: publishResult.state.messages ?? [], + organizationId: organization.id, + github, + detailsUrl: linkToWebsite, }); } @@ -1117,12 +1226,9 @@ export class SchemaPublisher { }; } - private async githubCheck({ - project, + private async githubSchemaCheck({ target, - organization, serviceName, - sha, conclusion, changes, breakingChanges, @@ -1130,12 +1236,17 @@ export class SchemaPublisher { errors, warnings, schemaCheckId, + ...args }: { - project: Project; + project: { + orgId: string; + cleanId: string; + name: string; + useProjectNameInGithubCheck: boolean; + }; target: Target; organization: Organization; serviceName: string | null; - sha: string; conclusion: SchemaCheckConclusion; warnings: SchemaCheckWarning[] | null; changes: Array | null; @@ -1147,14 +1258,12 @@ export class SchemaPublisher { message: string; }> | null; schemaCheckId: string | null; + github: { + repository: `${string}/${string}`; + sha: string; + }; }) { - if (!project.gitRepository) { - return { - __typename: 'GitHubSchemaCheckError' as const, - message: 'Git repository is not configured for this project', - }; - } - const [repositoryOwner, repositoryName] = project.gitRepository.split('/'); + const [repositoryOwner, repositoryName] = args.github.repository.split('/'); try { let title: string; @@ -1186,14 +1295,14 @@ export class SchemaPublisher { const checkRun = await this.gitHubIntegrationManager.createCheckRun({ name: buildGitHubActionCheckName({ - projectName: project.name, + projectName: args.project.name, targetName: target.name, serviceName, - includeProjectName: project.useProjectNameInGithubCheck, + includeProjectName: args.project.useProjectNameInGithubCheck, }), conclusion: conclusion === SchemaCheckConclusion.Success ? 'success' : 'failure', - sha, - organization: project.orgId, + sha: args.github.sha, + organization: args.project.orgId, repositoryOwner, repositoryName, output: { @@ -1203,9 +1312,9 @@ export class SchemaPublisher { detailsUrl: (schemaCheckId && this.schemaModuleConfig.schemaCheckLink?.({ - project, + project: args.project, target, - organization, + organization: args.organization, schemaCheckId, })) || null, @@ -1387,29 +1496,29 @@ export class SchemaPublisher { private async createPublishCheckRun({ initial, force, - input, - project, valid, changes, errors, messages, + organizationId, + github, + detailsUrl, }: { initial: boolean; force?: boolean | null; - input: PublishInput; - project: Project; valid: boolean; changes: Array; errors: readonly Types.SchemaError[]; messages?: string[]; + + organizationId: string; + github: { + repository: string; + sha: string; + }; + detailsUrl: string | null; }) { - if (!project.gitRepository) { - return { - __typename: 'GitHubSchemaPublishError' as const, - message: 'Git repository is not configured for this project', - }; - } - const [repositoryOwner, repositoryName] = project.gitRepository.split('/'); + const [repositoryOwner, repositoryName] = github.repository.split('/'); try { let title: string; @@ -1447,26 +1556,26 @@ export class SchemaPublisher { await this.gitHubIntegrationManager.createCheckRun({ name: 'GraphQL Hive - schema:publish', conclusion: valid ? 'success' : force ? 'neutral' : 'failure', - sha: input.commit, - organization: input.organization, + sha: github.sha, + organization: organizationId, repositoryOwner, repositoryName, output: { title, summary, }, - detailsUrl: null, + detailsUrl, }); return { - __typename: 'GitHubSchemaPublishSuccess' as const, + __typename: 'GitHubSchemaPublishSuccess', message: title, - }; - } catch (error: any) { + } as const; + } catch (error: unknown) { Sentry.captureException(error); return { - __typename: 'GitHubSchemaPublishError' as const, + __typename: 'GitHubSchemaPublishError', message: `Failed to create the check-run`, - }; + } as const; } } diff --git a/packages/services/api/src/modules/schema/resolvers.ts b/packages/services/api/src/modules/schema/resolvers.ts index c884b0092..9cba3c396 100644 --- a/packages/services/api/src/modules/schema/resolvers.ts +++ b/packages/services/api/src/modules/schema/resolvers.ts @@ -116,7 +116,7 @@ export const resolvers: SchemaModule.Resolvers = { target, }); - if ('changes' in result) { + if ('changes' in result && result.changes) { return { ...result, changes: result.changes.map(toGraphQLSchemaChange), @@ -753,7 +753,7 @@ export const resolvers: SchemaModule.Resolvers = { SchemaVersion: { async log(version, _, { injector }) { const log = await injector.get(SchemaManager).getSchemaLog({ - commit: version.commit, + commit: version.actionId, organization: version.organization, project: version.project, target: version.target, @@ -1022,6 +1022,10 @@ export const resolvers: SchemaModule.Resolvers = { }; }, date: version => version.createdAt, + githubMetadata(version, _, { injector }) { + return injector.get(SchemaManager).getGitHubMetadata(version); + }, + valid: version => version.isComposable, }, SchemaCompareError: { __isTypeOf(source: unknown) { diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 1eb14b8a2..ab82757f0 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -226,10 +226,6 @@ export interface Storage { _: ProjectSelector & Pick & { user: string }, ): Promise; - updateProjectGitRepository( - _: ProjectSelector & Pick, - ): Promise; - enableExternalSchemaComposition( _: ProjectSelector & { endpoint: string; @@ -407,6 +403,10 @@ export interface Storage { actionFn(): Promise; changes: Array; previousSchemaVersion: null | string; + github: null | { + repository: string; + sha: string; + }; } & TargetSelector) & ( | { diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index a7673094f..dcfab65ec 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -78,15 +78,19 @@ export interface DateRange { export interface SchemaVersion { id: string; - valid: boolean; createdAt: string; - commit: string; + isComposable: boolean; + actionId: string; baseSchema: string | null; hasPersistedSchemaChanges: boolean; previousSchemaVersionId: null | string; compositeSchemaSDL: null | string; supergraphSDL: null | string; schemaCompositionErrors: Array | null; + github: null | { + repository: string; + sha: string; + }; } export interface SchemaObject { @@ -261,7 +265,11 @@ export interface Project { type: ProjectType; buildUrl?: string | null; validationUrl?: string | null; - gitRepository?: string | null; + /** + * @deprecated A project is no longer linked to a single git repository as a project can be composed of multiple git repositories. + * TODO: All code referencing this field should be removed at some point. + */ + gitRepository?: `${string}/${string}` | null; legacyRegistryModel: boolean; useProjectNameInGithubCheck: boolean; externalComposition: { diff --git a/packages/services/api/src/shared/is-github-repository-string.ts b/packages/services/api/src/shared/is-github-repository-string.ts new file mode 100644 index 000000000..ea1ad75f3 --- /dev/null +++ b/packages/services/api/src/shared/is-github-repository-string.ts @@ -0,0 +1,13 @@ +/** + * Verify whether a string is a legit GitHub repository string. + * Example: `foo/bar` + */ +export function isGitHubRepositoryString(repository: string): repository is `${string}/${string}` { + const [owner, name] = repository.split('/'); + return !!owner && isLegitGitHubName(owner) && !!name && isLegitGitHubName(name); +} + +/** @source https://stackoverflow.com/a/59082561 */ +function isLegitGitHubName(str: string) { + return /^[\w-.]+$/i.test(str); +} diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index ad2f0ae55..018e9c1d9 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -174,6 +174,8 @@ export interface schema_checks { created_at: Date; expires_at: Date | null; github_check_run_id: string | null; + github_repository: string | null; + github_sha: string | null; id: string; is_manually_approved: boolean | null; is_success: boolean; @@ -233,6 +235,8 @@ export interface schema_versions { base_schema: string | null; composite_schema_sdl: string | null; created_at: Date; + github_repository: string | null; + github_sha: string | null; has_persisted_schema_changes: boolean | null; id: string; is_composable: boolean; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index bcba04fb4..9300059fb 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -204,7 +204,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) type: project.type as ProjectType, buildUrl: project.build_url, validationUrl: project.validation_url, - gitRepository: project.git_repository, + gitRepository: project.git_repository as `${string}/${string}` | null, legacyRegistryModel: project.legacy_registry_model, useProjectNameInGithubCheck: project.github_check_with_project_name === true, externalComposition: { @@ -1272,16 +1272,6 @@ export async function createStorage(connection: string, maximumPoolSize: number) `), ); }, - async updateProjectGitRepository({ gitRepository, organization, project }) { - return transformProject( - await pool.one>(sql` - UPDATE public.projects - SET git_repository = ${gitRepository ?? null} - WHERE id = ${project} AND org_id = ${organization} - RETURNING * - `), - ); - }, async enableExternalSchemaComposition({ project, endpoint, encryptedSecret }) { return transformProject( await pool.one>(sql` @@ -1710,16 +1700,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) const version = await pool.maybeOne( sql` SELECT - sv.id, - sv.is_composable, - to_json(sv.created_at) as "created_at", - sv.action_id, - sv.base_schema, - sv.has_persisted_schema_changes, - sv.previous_schema_version_id, - sv.composite_schema_sdl, - sv.supergraph_sdl, - sv.schema_composition_errors + ${schemaVersionSQLFields(sql`sv.`)} FROM public.schema_versions as sv WHERE sv.target_id = ${target} AND sv.is_composable IS TRUE ORDER BY sv.created_at DESC @@ -1737,16 +1718,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) const version = await pool.maybeOne( sql` SELECT - sv.id, - sv.is_composable, - to_json(sv.created_at) as "created_at", - sv.action_id, - sv.base_schema, - sv.has_persisted_schema_changes, - sv.previous_schema_version_id, - sv.composite_schema_sdl, - sv.supergraph_sdl, - sv.schema_composition_errors + ${schemaVersionSQLFields(sql`sv.`)} FROM public.schema_versions as sv WHERE sv.target_id = ${target} AND sv.is_composable IS TRUE ORDER BY sv.created_at DESC @@ -1760,16 +1732,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) const version = await pool.maybeOne( sql` SELECT - sv.id, - sv.is_composable, - to_json(sv.created_at) as "created_at", - sv.action_id, - sv.base_schema, - sv.has_persisted_schema_changes, - sv.previous_schema_version_id, - sv.composite_schema_sdl, - sv.supergraph_sdl, - sv.schema_composition_errors + ${schemaVersionSQLFields(sql`sv.`)} FROM public.schema_versions as sv LEFT JOIN public.targets as t ON (t.id = sv.target_id) WHERE sv.target_id = ${target} AND t.project_id = ${project} @@ -1785,16 +1748,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) const version = await pool.maybeOne( sql` SELECT - sv.id, - sv.is_composable, - to_json(sv.created_at) as "created_at", - sv.action_id, - sv.base_schema, - sv.has_persisted_schema_changes, - sv.previous_schema_version_id, - sv.composite_schema_sdl, - sv.supergraph_sdl, - sv.schema_composition_errors + ${schemaVersionSQLFields(sql`sv.`)} FROM public.schema_versions as sv LEFT JOIN public.targets as t ON (t.id = sv.target_id) WHERE sv.target_id = ${target} AND t.project_id = ${project} @@ -1950,16 +1904,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) async getVersion({ project, target, version }) { const result = await pool.one(sql` SELECT - sv.id, - sv.is_composable, - to_json(sv.created_at) as "created_at", - sv.base_schema, - sv.action_id, - sv.has_persisted_schema_changes, - sv.previous_schema_version_id, - sv.composite_schema_sdl, - sv.supergraph_sdl, - sv.schema_composition_errors + ${schemaVersionSQLFields(sql`sv.`)} FROM public.schema_versions as sv LEFT JOIN public.schema_log as sl ON (sl.id = sv.action_id) LEFT JOIN public.targets as t ON (t.id = sv.target_id) @@ -1976,18 +1921,9 @@ export async function createStorage(connection: string, maximumPoolSize: number) async getVersions({ project, target, after, limit }) { const query = sql` SELECT - sv.id, - sv.is_composable, - to_json(sv.created_at) as "created_at", - sv.base_schema, - sv.action_id, - sv.has_persisted_schema_changes, - sv.previous_schema_version_id, - sv.composite_schema_sdl, - sv.supergraph_sdl, - sv.schema_composition_errors, - sl.author, - lower(sl.service_name) as "service_name" + ${schemaVersionSQLFields(sql`sv.`)} + , sl.author as "author" + , lower(sl.service_name) as "service_name" FROM public.schema_versions as sv LEFT JOIN public.schema_log as sl ON (sl.id = sv.action_id) LEFT JOIN public.targets as t ON (t.id = sv.target_id) @@ -2058,6 +1994,8 @@ export async function createStorage(connection: string, maximumPoolSize: number) compositeSchemaSDL: args.compositeSchemaSDL, supergraphSDL: args.supergraphSDL, schemaCompositionErrors: args.schemaCompositionErrors, + // Deleting a schema is done via CLI and not associated to a commit or a pull request. + github: null, }); // Move all the schema_version_to_log entries of the previous version to the new version @@ -2139,6 +2077,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) compositeSchemaSDL: input.compositeSchemaSDL, supergraphSDL: input.supergraphSDL, schemaCompositionErrors: input.schemaCompositionErrors, + github: input.github, }); await Promise.all( @@ -2200,16 +2139,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) WHERE id = ${version} RETURNING - id, - is_composable, - to_json(created_at) as "created_at", - action_id, - base_schema, - has_persisted_schema_changes, - previous_schema_version_id, - composite_schema_sdl, - supergraph_sdl, - schema_composition_errors + ${schemaVersionSQLFields()} `), ); }, @@ -3524,6 +3454,8 @@ export async function createStorage(connection: string, maximumPoolSize: number) , "is_manually_approved" , "manual_approval_user_id" , "github_check_run_id" + , "github_repository" + , "github_sha" , "expires_at" ) VALUES ( @@ -3543,6 +3475,8 @@ export async function createStorage(connection: string, maximumPoolSize: number) , ${args.isManuallyApproved} , ${args.manualApprovalUserId} , ${args.githubCheckRunId} + , ${args.githubRepository} + , ${args.githubSha} , ${args.expiresAt?.toISOString() ?? null} ) RETURNING @@ -3944,33 +3878,39 @@ function decodeFeatureFlags(column: unknown) { return FeatureFlagsModel.parse(column); } -const SchemaVersionModel = zod - .object({ +const SchemaVersionModel = zod.intersection( + zod.object({ id: zod.string(), - is_composable: zod.boolean(), - created_at: zod.string(), - base_schema: zod.nullable(zod.string()), - action_id: zod.string(), - has_persisted_schema_changes: zod.nullable(zod.boolean()), - previous_schema_version_id: zod.nullable(zod.string()), - composite_schema_sdl: zod.nullable(zod.string()), - supergraph_sdl: zod.nullable(zod.string()), - schema_composition_errors: zod.nullable(zod.array(SchemaCompositionErrorModel)), - }) - .transform(value => ({ - id: value.id, - /** @deprecated Use isComposable instead. */ - valid: value.is_composable, - isComposable: value.is_composable, - createdAt: value.created_at, - baseSchema: value.base_schema, - commit: value.action_id, - hasPersistedSchemaChanges: value.has_persisted_schema_changes ?? false, - previousSchemaVersionId: value.previous_schema_version_id, - compositeSchemaSDL: value.composite_schema_sdl, - supergraphSDL: value.supergraph_sdl, - schemaCompositionErrors: value.schema_composition_errors, - })); + isComposable: zod.boolean(), + createdAt: zod.string(), + baseSchema: zod.nullable(zod.string()), + actionId: zod.string(), + hasPersistedSchemaChanges: zod.nullable(zod.boolean()).transform(val => val ?? false), + previousSchemaVersionId: zod.nullable(zod.string()), + compositeSchemaSDL: zod.nullable(zod.string()), + supergraphSDL: zod.nullable(zod.string()), + schemaCompositionErrors: zod.nullable(zod.array(SchemaCompositionErrorModel)), + }), + zod + .union([ + zod.object({ + githubRepository: zod.string(), + githubSha: zod.string(), + }), + zod.object({ + githubRepository: zod.null(), + githubSha: zod.null(), + }), + ]) + .transform(val => ({ + github: val.githubRepository + ? { + repository: val.githubRepository, + sha: val.githubSha, + } + : null, + })), +); const DocumentCollectionModel = zod.object({ id: zod.string(), @@ -4046,6 +3986,10 @@ async function insertSchemaVersion( compositeSchemaSDL: string | null; supergraphSDL: string | null; schemaCompositionErrors: Array | null; + github: null | { + sha: string; + repository: string; + }; }, ) { const query = sql` @@ -4059,7 +4003,9 @@ async function insertSchemaVersion( previous_schema_version_id, composite_schema_sdl, supergraph_sdl, - schema_composition_errors + schema_composition_errors, + github_repository, + github_sha ) VALUES ( @@ -4075,19 +4021,12 @@ async function insertSchemaVersion( args.schemaCompositionErrors ? sql`${JSON.stringify(args.schemaCompositionErrors)}::jsonb` : sql`${null}` - } + }, + ${args.github?.repository ?? null}, + ${args.github?.sha ?? null} ) RETURNING - id, - is_composable, - to_json(created_at) as "created_at", - action_id, - base_schema, - has_persisted_schema_changes, - previous_schema_version_id, - composite_schema_sdl, - supergraph_sdl, - schema_composition_errors + ${schemaVersionSQLFields()} `; return await trx.one(query).then(SchemaVersionModel.parse); @@ -4140,10 +4079,27 @@ const schemaCheckSQLFields = sql` , "composite_schema_sdl" as "compositeSchemaSDL" , "supergraph_sdl" as "supergraphSDL" , "github_check_run_id" as "githubCheckRunId" + , "github_repository" as "githubRepository" + , "github_sha" as "githubSha" , coalesce("is_manually_approved", false) as "isManuallyApproved" , "manual_approval_user_id" as "manualApprovalUserId" `; +const schemaVersionSQLFields = (t = sql``) => sql` + ${t}"id" + , ${t}"is_composable" as "isComposable" + , to_json(${t}"created_at") as "createdAt" + , ${t}"action_id" as "actionId" + , ${t}"base_schema" as "baseSchema" + , ${t}"has_persisted_schema_changes" as "hasPersistedSchemaChanges" + , ${t}"previous_schema_version_id" as "previousSchemaVersionId" + , ${t}"composite_schema_sdl" as "compositeSchemaSDL" + , ${t}"supergraph_sdl" as "supergraphSDL" + , ${t}"schema_composition_errors" as "schemaCompositionErrors" + , ${t}"github_repository" as "githubRepository" + , ${t}"github_sha" as "githubSha" +`; + const targetSQLFields = sql` "id", "clean_id" as "cleanId", diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index d71bc2f3e..2cdff9bdb 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -866,7 +866,12 @@ const SchemaCheckSharedFieldsModel = z.object({ commit: z.string(), }) .nullable(), + // github specific data githubCheckRunId: z.number().nullable(), + // TODO: these two always come together + // we need to improve the model code to reflect that + githubRepository: z.string().nullable(), + githubSha: z.string().nullable(), }); const SchemaCheckInputModel = z.intersection( diff --git a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/history.tsx b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/history.tsx index 57f5b2044..7196aac91 100644 --- a/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/history.tsx +++ b/packages/web/app/pages/[organizationId]/[projectId]/[targetId]/history.tsx @@ -43,6 +43,10 @@ const HistoryPage_VersionsPageQuery = graphql(` } } baseSchema + githubMetadata { + repository + commit + } } pageInfo { hasNextPage @@ -54,13 +58,11 @@ const HistoryPage_VersionsPageQuery = graphql(` // URQL's Infinite scrolling pattern // https://formidable.com/open-source/urql/docs/basics/ui-patterns/#infinite-scrolling function ListPage({ - gitRepository, variables, isLastPage, onLoadMore, versionId, }: { - gitRepository?: string; variables: { after: string; limit: number }; isLastPage: boolean; onLoadMore: (after: string) => void; @@ -129,12 +131,12 @@ function ListPage({ ) : null} - {gitRepository && 'commit' in version.log && version.log.commit ? ( + {version.githubMetadata ? ( associated with Git commit @@ -350,7 +352,6 @@ const TargetHistoryPageQuery = graphql(` } project(selector: { organization: $organizationId, project: $projectId }) { ...TargetLayout_CurrentProjectFragment - gitRepository } target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { id @@ -412,7 +413,6 @@ function HistoryPageContent() {
{pageVariables.map((variables, i) => ( - mutate({ - input: { - organization: router.organizationId, - project: router.projectId, - gitRepository: values.gitRepository === '' ? null : values.gitRepository, - }, - }).then(result => { - if (result.data?.updateProjectGitRepository.ok) { - notify('Updated Git repository', 'success'); - } else { - notify('Failed to update Git repository', 'error'); - } - }), - }); if (integrationQuery.fetching) { return null; @@ -131,165 +82,149 @@ function GitHubIntegration(props: { const githubIntegration = integrationQuery.data?.organization?.organization.gitHubIntegration; return ( -
- - - Git Repository - - Associate your project with a Git repository to enable commit linking and to allow CI - integration. -
- - Learn more about GitHub integration - -
-
- - {!!githubIntegration && !props.isProjectNameInGitHubCheckEnabled ? ( -
-
-
-
Use project's name in GitHub Check
+ + + Git Repository + + Associate your project with a Git repository to enable commit linking and to allow CI + integration. +
+ + Learn more about GitHub integration + +
+
+ + {githubIntegration ? ( + <> + {props.isProjectNameInGitHubCheckEnabled ? null : ( +
+
- Prevents GitHub Check name collisions when running{' '} - {docksLink ? ( - - - $ hive schema:check --github - - - ) : ( - $ hive schema:check --github - )} - for more than one project. -
-
-
-
Here's how it will look like in your CI pipeline:
-
- -
- +
+ Use project's name in GitHub Check
+
+ Prevents GitHub Check name collisions when running{' '} + {docksLink ? ( + + + $ hive schema:check --github + + + ) : ( + $ hive schema:check --github + )} + for more than one project. +
+
+
+
+ Here's how it will look like in your CI pipeline: +
+
+ +
+ +
-
- {props.organizationName} > schema:check > staging +
+ {props.organizationName} > schema:check > staging +
+
— No changes
-
— No changes
-
-
- -
-
-
- -
- -
+
+ +
+
+
+ +
+ +
-
- {props.organizationName} > schema:check > {props.projectName} > - staging +
+ {props.organizationName} > schema:check > {props.projectName} > + staging +
+
— No changes
-
— No changes
-
-
- -
-
- ) : null} - - {githubIntegration ? ( - <> -