feat: remember breaking change approvals in context of a pull request (#3359)

This commit is contained in:
Laurin Quast 2023-11-16 13:35:51 +01:00 committed by GitHub
parent 19b0c042a7
commit 21d246d815
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1668 additions and 505 deletions

View file

@ -0,0 +1,21 @@
---
'@graphql-hive/cli': minor
---
Associate schema checks with context ID for remembering approved breaking schema changes for subsequent schema checks when running the `schema:check` command.
If you are using the `--github` flag, all you need to do is to upgrade to this version. The `context` will be automatically be the pull request scope.
On pull request branch GitHub Action:
```bash
hive schema:check --github ./my-schema.graphql
```
If you are not using GitHub Repositories and Actions, you can manually provide a context ID with the `--contextId` flag.
```bash
hive schema:check --contextId "pull-request-69" ./my-schema.graphql
```
[Learn more in the product update.](https://the-guild.dev/graphql/hive/product-updates/2023-11-16-schema-check-breaking-change-approval-context)

View file

@ -40,6 +40,9 @@ const config: CodegenConfig = {
ID: 'string',
},
mappers: {
SchemaChange: '../shared/mappers#SchemaChange as SchemaChangeMapper',
SchemaChangeApproval:
'../shared/mappers#SchemaChangeApproval as SchemaChangeApprovalMapper',
SchemaChangeConnection:
'../shared/mappers#SchemaChangeConnection as SchemaChangeConnectionMapper',
SchemaErrorConnection:

View file

@ -452,12 +452,14 @@ export function initSeed() {
author: string;
commit: string;
},
contextId?: string,
) {
return await checkSchema(
{
sdl,
service,
meta,
contextId,
},
secret,
);

View file

@ -140,31 +140,55 @@ const SchemaCheckQuery = graphql(/* GraphQL */ `
commit
author
}
... on SuccessfulSchemaCheck {
safeSchemaChanges {
nodes {
criticality
criticalityReason
message
path
}
}
schemaPolicyWarnings {
edges {
node {
message
ruleId
start {
line
column
}
end {
line
column
}
safeSchemaChanges {
nodes {
criticality
criticalityReason
message
path
approval {
schemaCheckId
approvedAt
approvedBy {
id
displayName
}
}
}
}
breakingSchemaChanges {
nodes {
criticality
criticalityReason
message
path
approval {
schemaCheckId
approvedAt
approvedBy {
id
displayName
}
}
}
}
schemaPolicyWarnings {
edges {
node {
message
ruleId
start {
line
column
}
end {
line
column
}
}
}
}
... on SuccessfulSchemaCheck {
compositeSchemaSDL
supergraphSDL
}
@ -175,38 +199,6 @@ const SchemaCheckQuery = graphql(/* GraphQL */ `
path
}
}
safeSchemaChanges {
nodes {
criticality
criticalityReason
message
path
}
}
breakingSchemaChanges {
nodes {
criticality
criticalityReason
message
path
}
}
schemaPolicyWarnings {
edges {
node {
message
ruleId
start {
line
column
}
end {
line
column
}
}
}
}
schemaPolicyErrors {
edges {
node {
@ -223,8 +215,6 @@ const SchemaCheckQuery = graphql(/* GraphQL */ `
}
}
}
compositeSchemaSDL
supergraphSDL
}
}
}
@ -809,7 +799,7 @@ test.concurrent(
},
);
test('metadata is persisted', async () => {
test.concurrent('metadata is persisted', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject, organization } = await createOrg();
const { createToken, project, target } = await createProject(ProjectType.Single);
@ -879,10 +869,578 @@ test('metadata is persisted', async () => {
});
});
test('approve failed schema check that has breaking changes succeeds', async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, organization } = await createOrg();
const { createToken, project, target } = await createProject(ProjectType.Single);
test.concurrent(
'approve failed schema check that has breaking change status to successful and attaches meta information to the breaking change',
async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, organization } = await createOrg();
const { createToken, project, target } = await createProject(ProjectType.Single);
// Create a token with write rights
const writeToken = await createToken({
targetScopes: [
TargetAccessScope.Read,
TargetAccessScope.RegistryRead,
TargetAccessScope.RegistryWrite,
TargetAccessScope.Settings,
],
});
// Publish schema with write rights
const publishResult = await writeToken
.publishSchema({
sdl: /* GraphQL */ `
type Query {
ping: String
}
`,
})
.then(r => r.expectNoGraphQLErrors());
// Schema publish should be successful
expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
// Create a token with read rights
const readToken = await createToken({
targetScopes: [TargetAccessScope.RegistryRead],
projectScopes: [],
organizationScopes: [],
});
// Check schema with read rights
const checkResult = await readToken
.checkSchema(/* GraphQL */ `
type Query {
ping: Float
}
`)
.then(r => r.expectNoGraphQLErrors());
const check = checkResult.schemaCheck;
if (check.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${check.__typename}`);
}
const schemaCheckId = check.schemaCheck?.id;
if (schemaCheckId == null) {
throw new Error('Missing schema check id.');
}
const mutationResult = await execute({
document: ApproveFailedSchemaCheckMutation,
variables: {
input: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
schemaCheckId,
},
},
authToken: ownerToken,
}).then(r => r.expectNoGraphQLErrors());
expect(mutationResult).toEqual({
approveFailedSchemaCheck: {
ok: {
schemaCheck: {
__typename: 'SuccessfulSchemaCheck',
isApproved: true,
approvedBy: {
__typename: 'User',
},
},
},
error: null,
},
});
const schemaCheck = await execute({
document: SchemaCheckQuery,
variables: {
selector: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
},
id: schemaCheckId,
},
authToken: readToken.secret,
}).then(r => r.expectNoGraphQLErrors());
expect(schemaCheck).toMatchObject({
target: {
schemaCheck: {
__typename: 'SuccessfulSchemaCheck',
breakingSchemaChanges: {
nodes: [
{
approval: {
schemaCheckId,
approvedAt: expect.any(String),
approvedBy: {
id: expect.any(String),
displayName: expect.any(String),
},
},
},
],
},
},
},
});
},
);
test.concurrent(
'approving a schema check with contextId containing breaking changes allows the changes for subsequent checks with the same contextId',
async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, organization } = await createOrg();
const { createToken, project, target } = await createProject(ProjectType.Single);
// Create a token with write rights
const writeToken = await createToken({
targetScopes: [
TargetAccessScope.Read,
TargetAccessScope.RegistryRead,
TargetAccessScope.RegistryWrite,
TargetAccessScope.Settings,
],
});
// Publish schema with write rights
const publishResult = await writeToken
.publishSchema({
sdl: /* GraphQL */ `
type Query {
ping: String
}
`,
})
.then(r => r.expectNoGraphQLErrors());
// Schema publish should be successful
expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
// Create a token with read rights
const readToken = await createToken({
targetScopes: [TargetAccessScope.RegistryRead],
projectScopes: [],
organizationScopes: [],
});
const contextId = 'pr-69420';
// Check schema with read rights
const checkResult = await readToken
.checkSchema(
/* GraphQL */ `
type Query {
ping: Float
}
`,
undefined,
undefined,
contextId,
)
.then(r => r.expectNoGraphQLErrors());
const check = checkResult.schemaCheck;
if (check.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${check.__typename}`);
}
const schemaCheckId = check.schemaCheck?.id;
if (schemaCheckId == null) {
throw new Error('Missing schema check id.');
}
const mutationResult = await execute({
document: ApproveFailedSchemaCheckMutation,
variables: {
input: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
schemaCheckId,
},
},
authToken: ownerToken,
}).then(r => r.expectNoGraphQLErrors());
expect(mutationResult).toEqual({
approveFailedSchemaCheck: {
ok: {
schemaCheck: {
__typename: 'SuccessfulSchemaCheck',
isApproved: true,
approvedBy: {
__typename: 'User',
},
},
},
error: null,
},
});
const secondCheckResult = await readToken
.checkSchema(
/* GraphQL */ `
type Query {
ping: Float
}
`,
undefined,
undefined,
contextId,
)
.then(r => r.expectNoGraphQLErrors());
if (secondCheckResult.schemaCheck.__typename !== 'SchemaCheckSuccess') {
throw new Error(`Expected SchemaCheckSuccess, got ${check.__typename}`);
}
const newSchemaCheckId = secondCheckResult.schemaCheck.schemaCheck?.id;
if (newSchemaCheckId == null) {
throw new Error('Missing schema check id.');
}
const newSchemaCheck = await execute({
document: SchemaCheckQuery,
variables: {
selector: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
},
id: newSchemaCheckId,
},
authToken: readToken.secret,
}).then(r => r.expectNoGraphQLErrors());
expect(newSchemaCheck.target?.schemaCheck).toMatchObject({
id: newSchemaCheckId,
breakingSchemaChanges: {
nodes: [
{
approval: {
schemaCheckId,
approvedAt: expect.any(String),
approvedBy: {
id: expect.any(String),
displayName: expect.any(String),
},
},
},
],
},
});
},
);
test.concurrent(
'approving a schema check with contextId containing breaking changes does not allow the changes for subsequent checks with a different contextId',
async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, organization } = await createOrg();
const { createToken, project, target } = await createProject(ProjectType.Single);
// Create a token with write rights
const writeToken = await createToken({
targetScopes: [
TargetAccessScope.Read,
TargetAccessScope.RegistryRead,
TargetAccessScope.RegistryWrite,
TargetAccessScope.Settings,
],
});
// Publish schema with write rights
const publishResult = await writeToken
.publishSchema({
sdl: /* GraphQL */ `
type Query {
ping: String
}
`,
})
.then(r => r.expectNoGraphQLErrors());
// Schema publish should be successful
expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
// Create a token with read rights
const readToken = await createToken({
targetScopes: [TargetAccessScope.RegistryRead],
projectScopes: [],
organizationScopes: [],
});
const contextId = 'pr-69420';
// Check schema with read rights
const checkResult = await readToken
.checkSchema(
/* GraphQL */ `
type Query {
ping: Float
}
`,
undefined,
undefined,
contextId,
)
.then(r => r.expectNoGraphQLErrors());
const check = checkResult.schemaCheck;
if (check.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${check.__typename}`);
}
const schemaCheckId = check.schemaCheck?.id;
if (schemaCheckId == null) {
throw new Error('Missing schema check id.');
}
const mutationResult = await execute({
document: ApproveFailedSchemaCheckMutation,
variables: {
input: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
schemaCheckId,
},
},
authToken: ownerToken,
}).then(r => r.expectNoGraphQLErrors());
expect(mutationResult).toEqual({
approveFailedSchemaCheck: {
ok: {
schemaCheck: {
__typename: 'SuccessfulSchemaCheck',
isApproved: true,
approvedBy: {
__typename: 'User',
},
},
},
error: null,
},
});
const secondCheckResult = await readToken
.checkSchema(
/* GraphQL */ `
type Query {
ping: Float
}
`,
undefined,
undefined,
contextId + '|' + contextId,
)
.then(r => r.expectNoGraphQLErrors());
if (secondCheckResult.schemaCheck.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckSuccess, got ${check.__typename}`);
}
const newSchemaCheckId = secondCheckResult.schemaCheck.schemaCheck?.id;
if (newSchemaCheckId == null) {
throw new Error('Missing schema check id.');
}
const newSchemaCheck = await execute({
document: SchemaCheckQuery,
variables: {
selector: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
},
id: newSchemaCheckId,
},
authToken: readToken.secret,
}).then(r => r.expectNoGraphQLErrors());
expect(newSchemaCheck.target?.schemaCheck).toMatchObject({
id: newSchemaCheckId,
breakingSchemaChanges: {
nodes: [
{
approval: null,
},
],
},
});
},
);
test.concurrent(
'subsequent schema check with shared contextId that contains new breaking changes that have not been approved fails',
async () => {
const { createOrg, ownerToken } = await initSeed().createOwner();
const { createProject, organization } = await createOrg();
const { createToken, project, target } = await createProject(ProjectType.Single);
// Create a token with write rights
const writeToken = await createToken({
targetScopes: [
TargetAccessScope.Read,
TargetAccessScope.RegistryRead,
TargetAccessScope.RegistryWrite,
TargetAccessScope.Settings,
],
});
// Publish schema with write rights
const publishResult = await writeToken
.publishSchema({
sdl: /* GraphQL */ `
type Query {
ping: String
pong: String
}
`,
})
.then(r => r.expectNoGraphQLErrors());
// Schema publish should be successful
expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
// Create a token with read rights
const readToken = await createToken({
targetScopes: [TargetAccessScope.RegistryRead],
projectScopes: [],
organizationScopes: [],
});
const contextId = 'pr-69420';
// Check schema with read rights
const checkResult = await readToken
.checkSchema(
/* GraphQL */ `
type Query {
ping: Float
}
`,
undefined,
undefined,
contextId,
)
.then(r => r.expectNoGraphQLErrors());
const check = checkResult.schemaCheck;
if (check.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${check.__typename}`);
}
const schemaCheckId = check.schemaCheck?.id;
if (schemaCheckId == null) {
throw new Error('Missing schema check id.');
}
const mutationResult = await execute({
document: ApproveFailedSchemaCheckMutation,
variables: {
input: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
schemaCheckId,
},
},
authToken: ownerToken,
}).then(r => r.expectNoGraphQLErrors());
expect(mutationResult).toEqual({
approveFailedSchemaCheck: {
ok: {
schemaCheck: {
__typename: 'SuccessfulSchemaCheck',
isApproved: true,
approvedBy: {
__typename: 'User',
},
},
},
error: null,
},
});
const secondCheckResult = await readToken
.checkSchema(
/* GraphQL */ `
type Query {
ping: Float
pong: Float
}
`,
undefined,
undefined,
contextId,
)
.then(r => r.expectNoGraphQLErrors());
if (secondCheckResult.schemaCheck.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckSuccess, got ${check.__typename}`);
}
const newSchemaCheckId = secondCheckResult.schemaCheck.schemaCheck?.id;
if (newSchemaCheckId == null) {
throw new Error('Missing schema check id.');
}
const newSchemaCheck = await execute({
document: SchemaCheckQuery,
variables: {
selector: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
},
id: newSchemaCheckId,
},
authToken: readToken.secret,
}).then(r => r.expectNoGraphQLErrors());
expect(newSchemaCheck.target?.schemaCheck).toMatchObject({
id: newSchemaCheckId,
breakingSchemaChanges: {
nodes: [
{
approval: {
schemaCheckId,
approvedAt: expect.any(String),
approvedBy: {
id: expect.any(String),
displayName: expect.any(String),
},
},
},
{
approval: null,
},
],
},
});
},
);
test.concurrent('contextId that has more than 300 characters is not allowed', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createToken } = await createProject(ProjectType.Single);
// Create a token with write rights
const writeToken = await createToken({
@ -900,6 +1458,7 @@ test('approve failed schema check that has breaking changes succeeds', async ()
sdl: /* GraphQL */ `
type Query {
ping: String
pong: String
}
`,
})
@ -915,72 +1474,95 @@ test('approve failed schema check that has breaking changes succeeds', async ()
organizationScopes: [],
});
const contextId = '';
// Check schema with read rights
const checkResult = await readToken
.checkSchema(/* GraphQL */ `
type Query {
ping: Float
}
`)
.checkSchema(
/* GraphQL */ `
type Query {
ping: Float
}
`,
undefined,
undefined,
contextId,
)
.then(r => r.expectNoGraphQLErrors());
const check = checkResult.schemaCheck;
if (check.__typename !== 'SchemaCheckError') {
throw new Error(`Expected SchemaCheckError, got ${check.__typename}`);
}
const schemaCheckId = check.schemaCheck?.id;
if (schemaCheckId == null) {
throw new Error('Missing schema check id.');
}
const mutationResult = await execute({
document: ApproveFailedSchemaCheckMutation,
variables: {
input: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
schemaCheckId,
},
},
authToken: ownerToken,
}).then(r => r.expectNoGraphQLErrors());
expect(mutationResult).toEqual({
approveFailedSchemaCheck: {
ok: {
schemaCheck: {
__typename: 'SuccessfulSchemaCheck',
isApproved: true,
approvedBy: {
__typename: 'User',
},
expect(checkResult.schemaCheck).toMatchObject({
__typename: 'SchemaCheckError',
errors: {
nodes: [
{
message: 'Context ID must be at least 1 character long.',
},
},
error: null,
},
});
const schemaCheck = await execute({
document: SchemaCheckQuery,
variables: {
selector: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
},
id: schemaCheckId,
},
authToken: readToken.secret,
}).then(r => r.expectNoGraphQLErrors());
expect(schemaCheck).toMatchObject({
target: {
schemaCheck: {
__typename: 'SuccessfulSchemaCheck',
},
],
},
});
});
test.concurrent('contextId that has fewer than 1 characters is not allowed', async () => {
const { createOrg } = await initSeed().createOwner();
const { createProject } = await createOrg();
const { createToken } = await createProject(ProjectType.Single);
// Create a token with write rights
const writeToken = await createToken({
targetScopes: [
TargetAccessScope.Read,
TargetAccessScope.RegistryRead,
TargetAccessScope.RegistryWrite,
TargetAccessScope.Settings,
],
});
// Publish schema with write rights
const publishResult = await writeToken
.publishSchema({
sdl: /* GraphQL */ `
type Query {
ping: String
pong: String
}
`,
})
.then(r => r.expectNoGraphQLErrors());
// Schema publish should be successful
expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
// Create a token with read rights
const readToken = await createToken({
targetScopes: [TargetAccessScope.RegistryRead],
projectScopes: [],
organizationScopes: [],
});
const contextId = new Array(201).fill('A').join('');
// Check schema with read rights
const checkResult = await readToken
.checkSchema(
/* GraphQL */ `
type Query {
ping: Float
}
`,
undefined,
undefined,
contextId,
)
.then(r => r.expectNoGraphQLErrors());
expect(checkResult.schemaCheck).toMatchObject({
__typename: 'SchemaCheckError',
errors: {
nodes: [
{
message: 'Context ID cannot exceed length of 200 characters.',
},
],
},
});
});

View file

@ -221,12 +221,19 @@ test.concurrent(
expect(changes[0]).toMatchInlineSnapshot(`
{
approvalMetadata: null,
criticality: BREAKING,
id: b3cb5845edf64492571c7b5c5857b7f9,
isSafeBasedOnUsage: false,
message: Field 'bruv' was removed from object type 'Query',
meta: {
isRemovedFieldDeprecated: false,
removedFieldName: bruv,
typeName: Query,
typeType: object type,
},
path: Query.bruv,
reason: Removing a field is a breaking change. It is preferable to deprecate the field before removing it.,
type: FIELD_REMOVED,
}
`);

View file

@ -628,7 +628,10 @@ describe('schema publishing changes are persisted', () => {
name: string;
schemaBefore: string;
schemaAfter: string;
equalsObject: object;
equalsObject: {
meta: unknown;
type: unknown;
};
/** Only provide if you want to test a service url change */
serviceUrlAfter?: string;
}) {
@ -685,7 +688,8 @@ describe('schema publishing changes are persisted', () => {
versionId: latestVersion.id,
});
expect(changes[0]).toEqual(args.equalsObject);
expect(changes[0]['meta']).toEqual(args.equalsObject['meta']);
expect(changes[0]['type']).toEqual(args.equalsObject['type']);
});
}

View file

@ -29,8 +29,9 @@ const schemaCheckMutation = graphql(/* GraphQL */ `
}
changes {
nodes {
message
message(withSafeBasedOnUsageNote: false)
criticality
isSafeBasedOnUsage
}
total
}
@ -42,8 +43,9 @@ const schemaCheckMutation = graphql(/* GraphQL */ `
valid
changes {
nodes {
message
message(withSafeBasedOnUsageNote: false)
criticality
isSafeBasedOnUsage
}
total
}
@ -123,6 +125,9 @@ export default class SchemaCheck extends Command {
commit: Flags.string({
description: 'Associated commit sha',
}),
contextId: Flags.string({
description: 'Context ID for grouping the schema check.',
}),
};
static args = {
@ -172,21 +177,28 @@ export default class SchemaCheck extends Command {
let github: null | {
commit: string;
repository: string | null;
pullRequestNumber: string | null;
} = null;
if (usesGitHubApp) {
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.`);
if (!git.repository) {
throw new Errors.CLIError(
`Couldn't resolve git repository required for GitHub Application`,
);
}
if (!git.pullRequestNumber) {
throw new Errors.CLIError(
`Couldn't resolve pull request number required for GitHub Application`,
);
}
github = {
commit: commit,
repository,
repository: git.repository,
pullRequestNumber: git.pullRequestNumber,
};
}
@ -202,6 +214,7 @@ export default class SchemaCheck extends Command {
author,
}
: null,
contextId: flags.contextId ?? undefined,
},
usesGitHubApp,
});

View file

@ -20,8 +20,9 @@ const schemaPublishMutation = graphql(/* GraphQL */ `
linkToWebsite
changes {
nodes {
message
message(withSafeBasedOnUsageNote: false)
criticality
isSafeBasedOnUsage
}
total
}
@ -31,8 +32,9 @@ const schemaPublishMutation = graphql(/* GraphQL */ `
linkToWebsite
changes {
nodes {
message
message(withSafeBasedOnUsageNote: false)
criticality
isSafeBasedOnUsage
}
total
}

View file

@ -4,7 +4,11 @@ import ci from 'env-ci';
interface CIRunner {
detect(): boolean;
env(): { commit: string | undefined | null };
env(): {
commit: string | undefined | null;
pullRequestNumber: string | undefined | null;
repository: string | undefined | null;
};
}
const splitBy = '<##>';
@ -67,6 +71,9 @@ function useGitHubAction(): CIRunner {
if (event?.pull_request) {
return {
commit: event.pull_request.head.sha as string,
pullRequestNumber: String(event.pull_request.number),
// eslint-disable-next-line no-process-env
repository: process.env['GITHUB_REPOSITORY']!,
};
}
} catch {
@ -74,12 +81,21 @@ function useGitHubAction(): CIRunner {
}
}
return { commit: undefined };
return { commit: undefined, pullRequestNumber: undefined, repository: undefined };
},
};
}
export async function gitInfo(noGit: () => void) {
export type GitInfo = {
repository: string | null;
pullRequestNumber: string | null;
commit: string | null;
author: string | null;
};
export async function gitInfo(noGit: () => void): Promise<GitInfo> {
let repository: string | null = null;
let pullRequestNumber: string | null = null;
let commit: string | null = null;
let author: string | null = null;
@ -88,7 +104,10 @@ export async function gitInfo(noGit: () => void) {
const githubAction = useGitHubAction();
if (githubAction.detect()) {
commit = githubAction.env().commit ?? null;
const env = githubAction.env();
repository = env.repository ?? null;
commit = env.commit ?? null;
pullRequestNumber = env.pullRequestNumber ?? null;
}
if (!commit) {
@ -111,6 +130,8 @@ export async function gitInfo(noGit: () => void) {
}
return {
repository,
pullRequestNumber,
commit,
author,
};

View file

@ -10,7 +10,9 @@
"rootDir": "src",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}

View file

@ -7,6 +7,8 @@
"rootDir": "src",
"target": "es2017",
"module": "esnext",
"skipLibCheck": true
"skipLibCheck": true,
"declaration": true,
"declarationMap": true
}
}

View file

@ -7,6 +7,8 @@
"rootDir": "src",
"target": "es2017",
"module": "esnext",
"skipLibCheck": true
"skipLibCheck": true,
"declaration": true,
"declarationMap": true
}
}

View file

@ -7,6 +7,8 @@
"rootDir": "src",
"target": "es2017",
"module": "esnext",
"skipLibCheck": true
"skipLibCheck": true,
"declaration": true,
"declarationMap": true
}
}

View file

@ -0,0 +1,19 @@
import type { MigrationExecutor } from '../pg-migrator';
export default {
name: '2023.11.09T00.00.00.schema-check-approval.ts',
run: ({ sql }) => sql`
CREATE TABLE "public"."schema_change_approvals" (
"target_id" UUID NOT NULL REFERENCES "targets" ("id") ON DELETE CASCADE,
"context_id" text NOT NULL,
"schema_change_id" text NOT NULL,
"schema_change" jsonb NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY ("target_id", "context_id", "schema_change_id")
);
ALTER TABLE "public"."schema_checks"
ADD COLUMN "context_id" text
;
`,
} satisfies MigrationExecutor;

View file

@ -56,6 +56,7 @@ import migration_2023_10_05T11_44_36_schema_checks_github_repository from './act
import migration_2023_10_26T12_44_36_schema_checks_filters_index from './actions/2023.10.26T12.44.36.schema-checks-filters-index';
import migration_2023_10_30T00_00_00_drop_persisted_operations from './actions/2023.10.30T00-00-00.drop-persisted-operations';
import migration_2023_11_02T14_41_41_schema_checks_dedup from './actions/2023.11.02T14.41.41.schema-checks-dedup';
import migration_2023_11_09T00_00_00_schema_check_approval from './actions/2023.11.09T00.00.00.schema-check-approval';
import { runMigrations } from './pg-migrator';
export const runPGMigrations = (args: { slonik: DatabasePool; runTo?: string }) =>
@ -120,5 +121,6 @@ export const runPGMigrations = (args: { slonik: DatabasePool; runTo?: string })
migration_2023_10_26T12_44_36_schema_checks_filters_index,
migration_2023_10_30T00_00_00_drop_persisted_operations,
migration_2023_11_02T14_41_41_schema_checks_dedup,
migration_2023_11_09T00_00_00_schema_check_approval,
],
});

View file

@ -181,26 +181,24 @@ await describe('migration: schema-checks-dedup', async () => {
schemaPolicyWarnings: null,
schemaPolicyErrors: null,
expiresAt: null,
contextId: null,
});
// make sure SQL statements from Storage are capable of serving SDLs directly from schema_checks
const firstCheckFromStorage = await storage.findSchemaCheck({
schemaCheckId: firstSchemaCheck.id,
targetId: target.id,
});
assert.strictEqual(firstCheckFromStorage?.schemaSDL, schemaSDL);
assert.strictEqual(firstCheckFromStorage?.compositeSchemaSDL, compositeSchemaSDL);
assert.strictEqual(firstCheckFromStorage?.supergraphSDL, supergraphSDL);
const secondCheckFromStorage = await storage.findSchemaCheck({
schemaCheckId: secondSchemaCheck.id,
targetId: target.id,
});
assert.strictEqual(secondCheckFromStorage?.schemaSDL, secondSchemaSDL);
assert.strictEqual(secondCheckFromStorage?.compositeSchemaSDL, secondCompositeSchemaSDL);
assert.strictEqual(secondCheckFromStorage?.supergraphSDL, secondSupergraphSDL);
const thirdCheckFromStorage = await storage.findSchemaCheck({
schemaCheckId: thirdSchemaCheck.id,
targetId: target.id,
});
assert.strictEqual(thirdCheckFromStorage?.schemaSDL, schemaSDL);
assert.strictEqual(thirdCheckFromStorage?.compositeSchemaSDL, compositeSchemaSDL);
@ -209,7 +207,6 @@ await describe('migration: schema-checks-dedup', async () => {
// make sure SQL statements from Storage are capable of serving SDLs from sdl_store
const newCheckFromStorage = await storage.findSchemaCheck({
schemaCheckId: newSchemaCheck.id,
targetId: target.id,
});
assert.strictEqual(newCheckFromStorage?.schemaSDL, schemaSDL);
assert.strictEqual(newCheckFromStorage?.compositeSchemaSDL, compositeSchemaSDL);

View file

@ -1,6 +1,13 @@
import { Change, CriticalityLevel } from '@graphql-inspector/core';
import { CriticalityLevel } from '@graphql-inspector/core';
import type { SchemaChangeType } from '@hive/storage';
import type * as Types from '../../../../__generated__/types';
import { Alert, AlertChannel, Organization, Project, Target } from '../../../../shared/entities';
import type {
Alert,
AlertChannel,
Organization,
Project,
Target,
} from '../../../../shared/entities';
export interface SchemaChangeNotificationInput {
event: {
@ -12,7 +19,7 @@ export interface SchemaChangeNotificationInput {
commit: string;
valid: boolean;
};
changes: Array<Change>;
changes: Array<SchemaChangeType>;
messages: string[];
errors: Types.SchemaError[];
initial: boolean;
@ -61,5 +68,5 @@ export function quotesTransformer(msg: string, symbols = '**') {
}
export function filterChangesByLevel(level: CriticalityLevel) {
return (change: Change) => change.criticality.level === level;
return (change: SchemaChangeType) => change.criticality === level;
}

View file

@ -1,5 +1,6 @@
import { Inject, Injectable } from 'graphql-modules';
import { Change, CriticalityLevel } from '@graphql-inspector/core';
import { CriticalityLevel } from '@graphql-inspector/core';
import { SchemaChangeType } from '@hive/storage';
import { MessageAttachment, WebClient } from '@slack/web-api';
import { Logger } from '../../../shared/providers/logger';
import { WEB_APP_URL } from '../../../shared/providers/tokens';
@ -125,7 +126,7 @@ export class SlackCommunicationAdapter implements CommunicationAdapter {
}
}
function createAttachments(changes: readonly Change[], messages: readonly string[]) {
function createAttachments(changes: readonly SchemaChangeType[], messages: readonly string[]) {
const breakingChanges = changes.filter(filterChangesByLevel(CriticalityLevel.Breaking));
const dangerousChanges = changes.filter(filterChangesByLevel(CriticalityLevel.Dangerous));
const safeChanges = changes.filter(filterChangesByLevel(CriticalityLevel.NonBreaking));
@ -183,9 +184,18 @@ function renderAttachments({
}: {
color: string;
title: string;
changes: readonly Change[];
changes: readonly SchemaChangeType[];
}): MessageAttachment {
const text = changes.map(change => slackCoderize(change.message)).join('\n');
const text = changes
.map(change => {
let text = change.message;
if (change.isSafeBasedOnUsage) {
text += ' (safe based on usage)';
}
return slackCoderize(text);
})
.join('\n');
return {
mrkdwn_in: ['text'],

View file

@ -296,8 +296,37 @@ export default gql`
type SchemaChange {
criticality: CriticalityLevel!
criticalityReason: String
message: String!
message(
"""
Whether to include a note about the safety of the change based on usage data within the message.
"""
withSafeBasedOnUsageNote: Boolean = true
): String!
path: [String!]
"""
Approval metadata for this schema change.
This field is populated in case the breaking change was manually approved.
"""
approval: SchemaChangeApproval
"""
Whether the breaking change is safe based on usage data.
"""
isSafeBasedOnUsage: Boolean!
}
type SchemaChangeApproval {
"""
User that approved this schema change.
"""
approvedBy: User
"""
Date of the schema change approval.
"""
approvedAt: DateTime!
"""
ID of the schema check in which this change was first approved.
"""
schemaCheckId: ID!
}
type SchemaError {
@ -392,6 +421,11 @@ export default gql`
sdl: String!
github: GitHubSchemaCheckInput
meta: SchemaCheckMetaInput
"""
Optional context ID to group schema checks together.
Manually approved breaking changes will be memorized for schema checks with the same context id.
"""
contextId: String
}
input SchemaDeleteInput {
@ -405,6 +439,10 @@ export default gql`
The repository name of the schema check.
"""
repository: String
"""
The pull request number of the schema check.
"""
pullRequestNumber: String
}
input SchemaCompareInput {

View file

@ -1,6 +1,7 @@
import type { GraphQLSchema } from 'graphql';
import { Injectable, Scope } from 'graphql-modules';
import { Change, CriticalityLevel, diff, DiffRule } from '@graphql-inspector/core';
import { diff, DiffRule } from '@graphql-inspector/core';
import { HiveSchemaChangeModel, SchemaChangeType } from '@hive/storage';
import type * as Types from '../../../__generated__/types';
import type { TargetSettings } from '../../../shared/entities';
import { createPeriod } from '../../../shared/helpers';
@ -9,21 +10,6 @@ import { OperationsManager } from '../../operations/providers/operations-manager
import { Logger } from '../../shared/providers/logger';
import { TargetManager } from '../../target/providers/target-manager';
const criticalityMap: Record<CriticalityLevel, Types.CriticalityLevel> = {
[CriticalityLevel.Breaking]: 'Breaking',
[CriticalityLevel.NonBreaking]: 'Safe',
[CriticalityLevel.Dangerous]: 'Dangerous',
};
export function toGraphQLSchemaChange(change: Change): Types.SchemaChange {
return {
message: change.message,
path: change.path?.split('.') ?? null,
criticality: criticalityMap[change.criticality.level],
criticalityReason: change.criticality.reason ?? null,
};
}
@Injectable({
scope: Scope.Operation,
})
@ -43,7 +29,7 @@ export class Inspector {
existing: GraphQLSchema,
incoming: GraphQLSchema,
selector?: Types.TargetSelector,
): Promise<Array<Change>> {
): Promise<Array<SchemaChangeType>> {
this.logger.debug('Comparing Schemas');
const changes = await diff(existing, incoming, [DiffRule.considerUsage], {
@ -103,7 +89,15 @@ export class Inspector {
},
});
return changes.sort((a, b) => a.criticality.level.localeCompare(b.criticality.level));
return changes
.map(change =>
HiveSchemaChangeModel.parse({
type: change.type,
meta: change.meta,
isSafeBasedOnUsage: change.criticality.isSafeBasedOnUsage,
}),
)
.sort((a, b) => a.criticality.localeCompare(b.criticality));
}
private async getSettings({ selector }: { selector: Types.TargetSelector }) {

View file

@ -109,6 +109,7 @@ export class CompositeLegacyModel {
selector,
version: latestVersion,
includeUrlChanges: false,
approvedChanges: null,
}),
]);
@ -126,7 +127,7 @@ export class CompositeLegacyModel {
return {
conclusion: SchemaCheckConclusion.Success,
state: {
schemaChanges: diffCheck.result?.changes ?? null,
schemaChanges: diffCheck.result ?? null,
schemaPolicyWarnings: null,
composition: {
compositeSchemaSDL: compositionCheck.result.fullSchemaSdl,
@ -253,6 +254,7 @@ export class CompositeLegacyModel {
schemas,
version: latestVersion,
includeUrlChanges: true,
approvedChanges: null,
}),
isFederation
? {
@ -264,10 +266,8 @@ export class CompositeLegacyModel {
const compositionErrors =
compositionCheck.status === 'failed' ? compositionCheck.reason.errors : null;
const breakingChanges =
diffCheck.status === 'failed' && !acceptBreakingChanges
? diffCheck.reason.breakingChanges
: null;
const changes = diffCheck.result?.changes || diffCheck.reason?.changes || null;
diffCheck.status === 'failed' && !acceptBreakingChanges ? diffCheck.reason.breaking : null;
const changes = diffCheck.result?.all || diffCheck.reason?.all || null;
const hasNewUrl =
serviceUrlCheck.status === 'completed' && serviceUrlCheck.result.status === 'modified';
@ -329,8 +329,8 @@ export class CompositeLegacyModel {
if (diffCheck.status === 'failed' && !acceptBreakingChanges) {
reasons.push({
code: PublishFailureReasonCode.BreakingChanges,
changes: diffCheck.reason.changes ?? [],
breakingChanges: diffCheck.reason.breakingChanges ?? [],
changes: diffCheck.reason.all ?? [],
breakingChanges: diffCheck.reason.breaking ?? [],
});
}

View file

@ -1,4 +1,5 @@
import { Injectable, Scope } from 'graphql-modules';
import { SchemaChangeType } from '@hive/storage';
import { FederationOrchestrator } from '../orchestrators/federation';
import { StitchingOrchestrator } from '../orchestrators/stitching';
import { RegistryChecks } from '../registry-checks';
@ -45,6 +46,7 @@ export class CompositeModel {
project,
organization,
baseSchema,
approvedChanges,
}: {
input: {
sdl: string;
@ -66,6 +68,7 @@ export class CompositeModel {
baseSchema: string | null;
project: Project;
organization: Organization;
approvedChanges: Map<string, SchemaChangeType>;
}): Promise<SchemaCheckResult> {
const incoming: PushedCompositeSchema = {
kind: 'composite',
@ -121,6 +124,7 @@ export class CompositeModel {
selector,
version: compareToLatest ? latest : latestComposable,
includeUrlChanges: false,
approvedChanges,
}),
this.checks.policyCheck({
orchestrator,
@ -152,7 +156,7 @@ export class CompositeModel {
conclusion: SchemaCheckConclusion.Success,
state: {
schemaPolicyWarnings: policyCheck.result?.warnings ?? null,
schemaChanges: diffCheck.result?.changes ?? null,
schemaChanges: diffCheck.result ?? null,
composition: {
compositeSchemaSDL: compositionCheck.result.fullSchemaSdl,
supergraphSDL: compositionCheck.result.supergraph,
@ -281,6 +285,7 @@ export class CompositeModel {
},
version: compareToLatest ? latest : latestComposable,
includeUrlChanges: true,
approvedChanges: null,
}),
]);
@ -332,7 +337,7 @@ export class CompositeModel {
state: {
composable: compositionCheck.status === 'completed',
initial: latestVersion === null,
changes: diffCheck.result?.changes ?? diffCheck.reason?.changes ?? null,
changes: diffCheck.result?.all ?? diffCheck.reason?.all ?? null,
messages,
breakingChanges: null,
compositionErrors: compositionCheck.reason?.errors ?? null,
@ -422,6 +427,7 @@ export class CompositeModel {
selector,
version: compareToLatest ? latestVersion : latestComposable,
includeUrlChanges: true,
approvedChanges: null,
}),
]);
@ -445,11 +451,11 @@ export class CompositeModel {
const { changes, breakingChanges } =
diffCheck.status === 'failed'
? {
changes: diffCheck.reason.changes ?? [],
breakingChanges: diffCheck.reason.breakingChanges ?? [],
changes: diffCheck.reason.all ?? [],
breakingChanges: diffCheck.reason.breaking ?? [],
}
: {
changes: diffCheck.result?.changes ?? [],
changes: diffCheck.result?.all ?? [],
breakingChanges: [],
};

View file

@ -1,8 +1,7 @@
import { PushedCompositeSchema, SingleSchema } from 'packages/services/api/src/shared/entities';
import { Change } from '@graphql-inspector/core';
import type { CheckPolicyResponse } from '@hive/policy';
import { CompositionFailureError } from '@hive/schema';
import type { SchemaCompositionError } from '@hive/storage';
import type { SchemaChangeType, SchemaCompositionError } from '@hive/storage';
import { type RegistryChecks } from '../registry-checks';
export const SchemaPublishConclusion = {
@ -81,7 +80,11 @@ export type SchemaCheckSuccess = {
conclusion: (typeof SchemaCheckConclusion)['Success'];
// state is null in case the check got skipped.
state: null | {
schemaChanges: Array<Change> | null;
schemaChanges: null | {
breaking: Array<SchemaChangeType> | null;
safe: Array<SchemaChangeType> | null;
all: Array<SchemaChangeType> | null;
};
schemaPolicyWarnings: SchemaCheckWarning[] | null;
composition: {
compositeSchemaSDL: string;
@ -108,8 +111,9 @@ export type SchemaCheckFailure = {
};
/** Absence means schema changes were skipped. */
schemaChanges: null | {
breaking: Array<Change> | null;
safe: Array<Change> | null;
breaking: Array<SchemaChangeType> | null;
safe: Array<SchemaChangeType> | null;
all: Array<SchemaChangeType> | null;
};
/** Absence means the schema policy is disabled or wasn't done because composition failed. */
schemaPolicy: null | {
@ -156,8 +160,8 @@ export type SchemaPublishFailureReason =
}
| {
code: (typeof PublishFailureReasonCode)['BreakingChanges'];
breakingChanges: Array<Change>;
changes: Array<Change>;
breakingChanges: Array<SchemaChangeType>;
changes: Array<SchemaChangeType>;
};
type SchemaPublishSuccess = {
@ -165,7 +169,7 @@ type SchemaPublishSuccess = {
state: {
composable: boolean;
initial: boolean;
changes: Array<Change> | null;
changes: Array<SchemaChangeType> | null;
messages: string[] | null;
breakingChanges: Array<{
message: string;
@ -213,8 +217,8 @@ export type SchemaDeleteFailureReason =
export type SchemaDeleteSuccess = {
conclusion: (typeof SchemaDeleteConclusion)['Accept'];
state: {
changes: Array<Change> | null;
breakingChanges: Array<Change> | null;
changes: Array<SchemaChangeType> | null;
breakingChanges: Array<SchemaChangeType> | null;
compositionErrors: Array<SchemaCompositionError> | null;
supergraph: string | null;
} & (
@ -262,10 +266,11 @@ export function buildSchemaCheckFailureState(args: {
policyCheck: Awaited<ReturnType<RegistryChecks['policyCheck']>> | null;
}): SchemaCheckFailure['state'] {
const compositionErrors: Array<CompositionFailureError> = [];
let schemaChanges: null | {
breaking: Array<Change> | null;
safe: Array<Change> | null;
} = null;
const schemaChanges: null | {
breaking: Array<SchemaChangeType> | null;
safe: Array<SchemaChangeType> | null;
all: Array<SchemaChangeType> | null;
} = args.diffCheck.reason ?? args.diffCheck.result ?? null;
let schemaPolicy: null | {
errors: SchemaCheckWarning[] | null;
warnings: SchemaCheckWarning[] | null;
@ -275,20 +280,6 @@ export function buildSchemaCheckFailureState(args: {
compositionErrors.push(...args.compositionCheck.reason.errors);
}
if (args.diffCheck.reason) {
schemaChanges = {
breaking: args.diffCheck.reason.breakingChanges,
safe: args.diffCheck.reason.safeChanges,
};
}
if (args.diffCheck.result) {
schemaChanges = {
breaking: null,
safe: args.diffCheck.result.changes,
};
}
if (args.policyCheck) {
schemaPolicy = {
errors: args.policyCheck?.reason?.errors ?? null,

View file

@ -94,6 +94,7 @@ export class SingleLegacyModel {
selector,
version: latestVersion,
includeUrlChanges: false,
approvedChanges: null,
}),
]);
@ -111,7 +112,7 @@ export class SingleLegacyModel {
return {
conclusion: SchemaCheckConclusion.Success,
state: {
schemaChanges: diffCheck.result?.changes ?? null,
schemaChanges: diffCheck.result ?? null,
schemaPolicyWarnings: null,
composition: {
compositeSchemaSDL: compositionCheck.result.fullSchemaSdl,
@ -196,6 +197,7 @@ export class SingleLegacyModel {
schemas,
version: latestVersion,
includeUrlChanges: false,
approvedChanges: null,
}),
this.checks.metadata(incoming, latestVersion ? latestVersion.schemas[0] : null),
]);
@ -203,10 +205,8 @@ export class SingleLegacyModel {
const compositionErrors =
compositionCheck.status === 'failed' ? compositionCheck.reason.errors : null;
const breakingChanges =
diffCheck.status === 'failed' && !acceptBreakingChanges
? diffCheck.reason.breakingChanges
: null;
const changes = diffCheck.result?.changes || diffCheck.reason?.changes || null;
diffCheck.status === 'failed' && !acceptBreakingChanges ? diffCheck.reason.breaking : null;
const changes = diffCheck.result?.all || diffCheck.reason?.all || null;
const hasNewMetadata =
metadataCheck.status === 'completed' && metadataCheck.result.status === 'modified';
@ -267,8 +267,8 @@ export class SingleLegacyModel {
if (diffCheck.status === 'failed' && !acceptBreakingChanges) {
reasons.push({
code: PublishFailureReasonCode.BreakingChanges,
changes: diffCheck.reason.changes ?? [],
breakingChanges: diffCheck.reason.breakingChanges ?? [],
changes: diffCheck.reason.all ?? [],
breakingChanges: diffCheck.reason.breaking ?? [],
});
}

View file

@ -1,4 +1,5 @@
import { Injectable, Scope } from 'graphql-modules';
import { SchemaChangeType } from '@hive/storage';
import { SingleOrchestrator } from '../orchestrators/single';
import { RegistryChecks } from '../registry-checks';
import type { PublishInput } from '../schema-publisher';
@ -33,6 +34,7 @@ export class SingleModel {
project,
organization,
baseSchema,
approvedChanges,
}: {
input: {
sdl: string;
@ -53,6 +55,7 @@ export class SingleModel {
baseSchema: string | null;
project: Project;
organization: Organization;
approvedChanges: Map<string, SchemaChangeType>;
}): Promise<SchemaCheckResult> {
const incoming: SingleSchema = {
kind: 'single',
@ -99,6 +102,7 @@ export class SingleModel {
selector,
version: compareToLatest ? latest : latestComposable,
includeUrlChanges: false,
approvedChanges,
}),
this.checks.policyCheck({
orchestrator: this.orchestrator,
@ -129,7 +133,7 @@ export class SingleModel {
return {
conclusion: SchemaCheckConclusion.Success,
state: {
schemaChanges: diffCheck.result?.changes ?? null,
schemaChanges: diffCheck.result ?? null,
schemaPolicyWarnings: policyCheck.result?.warnings ?? null,
composition: {
compositeSchemaSDL: compositionCheck.result.fullSchemaSdl,
@ -218,6 +222,7 @@ export class SingleModel {
},
version: compareToLatest ? latestVersion : latestComposable,
includeUrlChanges: false,
approvedChanges: null,
}),
]);
@ -263,7 +268,7 @@ export class SingleModel {
state: {
composable: compositionCheck.status === 'completed',
initial: latestVersion === null,
changes: diffCheck.result?.changes ?? diffCheck.reason?.changes ?? null,
changes: diffCheck.result?.all ?? diffCheck.reason?.all ?? null,
messages,
breakingChanges: null,
compositionErrors: compositionCheck.reason?.errors ?? null,

View file

@ -2,16 +2,17 @@ import { URL } from 'node:url';
import type { GraphQLSchema } from 'graphql';
import { Injectable, Scope } from 'graphql-modules';
import hashObject from 'object-hash';
import { CriticalityLevel, type Change } from '@graphql-inspector/core';
import { CriticalityLevel } from '@graphql-inspector/core';
import type { CheckPolicyResponse } from '@hive/policy';
import type { CompositionFailureError } from '@hive/schema';
import {
HiveSchemaChangeModel,
SchemaChangeType,
type RegistryServiceUrlChangeSerializableChange,
} from '@hive/storage';
import { ProjectType, Schema } from '../../../shared/entities';
import { buildSortedSchemaFromSchemaObject } from '../../../shared/schema';
import { SchemaPolicyProvider } from '../../policy/providers/schema-policy.provider';
import {
RegistryServiceUrlChangeSerializableChange,
schemaChangeFromMeta,
} from '../schema-change-from-meta';
import type {
Orchestrator,
Organization,
@ -231,6 +232,7 @@ export class RegistryChecks {
version,
selector,
includeUrlChanges,
approvedChanges,
}: {
orchestrator: Orchestrator;
project: Project;
@ -243,6 +245,8 @@ export class RegistryChecks {
target: string;
};
includeUrlChanges: boolean;
/** Lookup map of changes that are approved and thus safe. */
approvedChanges: null | Map<string, SchemaChangeType>;
}) {
if (!version || version.schemas.length === 0) {
this.logger.debug('Skipping diff check, no existing version');
@ -297,49 +301,66 @@ export class RegistryChecks {
} satisfies CheckResult;
}
const changes = [...(await this.inspector.diff(existingSchema, incomingSchema, selector))];
const inspectorChanges = await this.inspector.diff(existingSchema, incomingSchema, selector);
if (includeUrlChanges) {
changes.push(
...detectUrlChanges(version.schemas, schemas).map(change =>
schemaChangeFromMeta({
...change,
isSafeBasedOnUsage: false,
}),
),
);
inspectorChanges.push(...detectUrlChanges(version.schemas, schemas));
}
const safeChanges: Array<Change> = [];
const breakingChanges: Array<Change> = [];
for (const change of changes) {
if (change.criticality.level === CriticalityLevel.Breaking) {
let isFailure = false;
const safeChanges: Array<SchemaChangeType> = [];
const breakingChanges: Array<SchemaChangeType> = [];
for (const change of inspectorChanges) {
if (change.isSafeBasedOnUsage === true) {
breakingChanges.push(change);
} else if (change.criticality === CriticalityLevel.Breaking) {
// If this change is approved, we return the already approved on instead of the newly detected one,
// as it it contains the necessary metadata on when the change got first approved and by whom.
const approvedChange = approvedChanges?.get(change.id);
if (approvedChange) {
breakingChanges.push(approvedChange);
continue;
}
isFailure = true;
breakingChanges.push(change);
continue;
}
safeChanges.push(change);
}
if (breakingChanges.length > 0) {
if (isFailure === true) {
this.logger.debug('Detected breaking changes');
return {
status: 'failed',
reason: {
breakingChanges,
safeChanges: safeChanges.length ? safeChanges : null,
changes,
breaking: breakingChanges,
safe: safeChanges.length ? safeChanges : null,
get all() {
if (breakingChanges.length || safeChanges.length) {
return [...breakingChanges, ...safeChanges];
}
return null;
},
},
} satisfies CheckResult;
}
if (changes.length) {
if (inspectorChanges.length) {
this.logger.debug('Detected non-breaking changes');
}
return {
status: 'completed',
result: {
changes: changes.length ? changes : null,
breaking: breakingChanges.length ? breakingChanges : null,
safe: safeChanges.length ? safeChanges : null,
get all() {
if (breakingChanges.length || safeChanges.length) {
return [...breakingChanges, ...safeChanges];
}
return null;
},
},
} satisfies CheckResult;
}
@ -489,7 +510,7 @@ export class RegistryChecks {
export function detectUrlChanges(
schemasBefore: readonly Schema[],
schemasAfter: readonly Schema[],
): Array<RegistryServiceUrlChangeSerializableChange> {
): Array<SchemaChangeType> {
if (schemasBefore.length === 0) {
return [];
}
@ -548,7 +569,13 @@ export function detectUrlChanges(
}
}
return changes;
return changes.map(change =>
HiveSchemaChangeModel.parse({
type: change.type,
meta: change.meta,
isSafeBasedOnUsage: false,
}),
);
}
const toSchemaCheckWarning = (record: CheckPolicyResponse[number]): SchemaCheckWarning => ({

View file

@ -2,8 +2,7 @@ import { parse } from 'graphql';
import { Inject, Injectable, Scope } from 'graphql-modules';
import lodash from 'lodash';
import { z } from 'zod';
import { Change } from '@graphql-inspector/core';
import type { SchemaCheck, SchemaCompositionError } from '@hive/storage';
import type { SchemaChangeType, SchemaCheck, SchemaCompositionError } from '@hive/storage';
import { RegistryModel, SchemaChecksFilter } from '../../../__generated__/types';
import {
DateRange,
@ -30,7 +29,6 @@ import {
TargetSelector,
} from '../../shared/providers/storage';
import { TargetManager } from '../../target/providers/target-manager';
import { schemaChangeFromMeta } from '../schema-change-from-meta';
import { SCHEMA_MODULE_CONFIG, type SchemaModuleConfig } from './config';
import { FederationOrchestrator } from './orchestrators/federation';
import { SingleOrchestrator } from './orchestrators/single';
@ -328,7 +326,7 @@ export class SchemaManager {
metadata: string | null;
projectType: ProjectType;
actionFn(): Promise<void>;
changes: Array<Change>;
changes: Array<SchemaChangeType>;
previousSchemaVersion: string | null;
github: null | {
repository: string;
@ -587,15 +585,13 @@ export class SchemaManager {
};
}
async getPaginatedSchemaChecksForTarget<
TransformedSchemaCheck extends InflatedSchemaCheck,
>(args: {
async getPaginatedSchemaChecksForTarget<TransformedSchemaCheck extends SchemaCheck>(args: {
organizationId: string;
projectId: string;
targetId: string;
first: number | null;
cursor: string | null;
transformNode: (check: InflatedSchemaCheck) => TransformedSchemaCheck;
transformNode: (check: SchemaCheck) => TransformedSchemaCheck;
filters: SchemaChecksFilter | null;
}) {
await this.authManager.ensureTargetAccess({
@ -609,7 +605,7 @@ export class SchemaManager {
targetId: args.targetId,
first: args.first,
cursor: args.cursor,
transformNode: node => args.transformNode(inflateSchemaCheck(node)),
transformNode: node => args.transformNode(node),
filters: args.filters,
});
@ -631,7 +627,6 @@ export class SchemaManager {
});
const schemaCheck = await this.storage.findSchemaCheck({
targetId: args.targetId,
schemaCheckId: args.schemaCheckId,
});
@ -640,7 +635,7 @@ export class SchemaManager {
return null;
}
return inflateSchemaCheck(schemaCheck);
return schemaCheck;
}
async getSchemaCheckWebUrl(args: {
@ -722,13 +717,12 @@ export class SchemaManager {
let [schemaCheck, viewer] = await Promise.all([
this.storage.findSchemaCheck({
targetId: args.targetId,
schemaCheckId: args.schemaCheckId,
}),
this.authManager.getCurrentUser(),
]);
if (schemaCheck == null) {
if (schemaCheck == null || schemaCheck.targetId !== args.targetId) {
this.logger.debug('Schema check not found (args=%o)', args);
return {
type: 'error',
@ -804,7 +798,7 @@ export class SchemaManager {
return {
type: 'ok',
schemaCheck: inflateSchemaCheck(schemaCheck),
schemaCheck,
} as const;
}
@ -924,36 +918,15 @@ export class SchemaManager {
return null;
}
}
/**
* Takes a schema check as read from the database and inflates it to the proper business logic type.
*/
export function inflateSchemaCheck(schemaCheck: SchemaCheck) {
if (schemaCheck.isSuccess) {
return {
...schemaCheck,
safeSchemaChanges:
// TODO: fix any type
schemaCheck.safeSchemaChanges?.map((check: any) => schemaChangeFromMeta(check)) ?? null,
// TODO: fix any type
breakingSchemaChanges:
schemaCheck.breakingSchemaChanges?.map((check: any) => schemaChangeFromMeta(check)) ?? null,
};
async getUserForSchemaChangeById(input: { userId: string }) {
this.logger.info('Load user by id. (userId=%%)', input.userId);
const user = await this.storage.getUserById({ id: input.userId });
if (user) {
this.logger.info('User found. (userId=%s)', input.userId);
return user;
}
this.logger.info('User not found. (userId=%s)', input.userId);
return null;
}
return {
...schemaCheck,
safeSchemaChanges:
// TODO: fix any type
schemaCheck.safeSchemaChanges?.map((check: any) => schemaChangeFromMeta(check)) ?? null,
// TODO: fix any type
breakingSchemaChanges:
schemaCheck.breakingSchemaChanges?.map((check: any) => schemaChangeFromMeta(check)) ?? null,
};
}
/**
* Schema check with all the fields inflated to their proper types.
*/
export type InflatedSchemaCheck = ReturnType<typeof inflateSchemaCheck>;

View file

@ -2,8 +2,9 @@ import { parse, print } from 'graphql';
import { Inject, Injectable, Scope } from 'graphql-modules';
import lodash from 'lodash';
import promClient from 'prom-client';
import { Change, CriticalityLevel } from '@graphql-inspector/core';
import { SchemaCheck } from '@hive/storage';
import { z } from 'zod';
import { CriticalityLevel } from '@graphql-inspector/core';
import { SchemaChangeType, SchemaCheck } from '@hive/storage';
import * as Sentry from '@sentry/node';
import * as Types from '../../../__generated__/types';
import {
@ -55,7 +56,7 @@ import {
import { SingleModel } from './models/single';
import { SingleLegacyModel } from './models/single-legacy';
import { ensureCompositeSchemas, ensureSingleSchema, SchemaHelper } from './schema-helper';
import { inflateSchemaCheck, SchemaManager } from './schema-manager';
import { SchemaManager } from './schema-manager';
const schemaCheckCount = new promClient.Counter({
name: 'registry_check_count',
@ -310,6 +311,28 @@ export class SchemaPublisher {
}
}
let contextId: string | null = null;
if (input.contextId !== undefined) {
const result = SchemaCheckContextIdModel.safeParse(input.contextId);
if (!result.success) {
return {
__typename: 'SchemaCheckError',
valid: false,
changes: [],
warnings: [],
errors: [
{
message: result.error.errors[0].message,
},
],
} as const;
}
contextId = result.data;
} else if (input.github?.repository && input.github.pullRequestNumber) {
contextId = `${input.github.repository}#${input.github.pullRequestNumber}`;
}
await this.schemaManager.completeGetStartedCheck({
organization: project.orgId,
step: 'checkingSchema',
@ -331,6 +354,18 @@ export class SchemaPublisher {
let checkResult: SchemaCheckResult;
const approvedSchemaChanges = new Map<string, SchemaChangeType>();
if (contextId !== null) {
const changes = await this.storage.getApprovedSchemaChangesForContextId({
targetId: target.id,
contextId,
});
for (const change of changes) {
approvedSchemaChanges.set(change.id, change);
}
}
switch (project.type) {
case ProjectType.SINGLE:
this.logger.debug('Using SINGLE registry model (version=%s)', projectModelVersion);
@ -352,6 +387,7 @@ export class SchemaPublisher {
baseSchema,
project,
organization,
approvedChanges: approvedSchemaChanges,
});
break;
case ProjectType.FEDERATION:
@ -387,6 +423,7 @@ export class SchemaPublisher {
baseSchema,
project,
organization,
approvedChanges: approvedSchemaChanges,
});
break;
default:
@ -449,6 +486,7 @@ export class SchemaPublisher {
: null,
githubSha: githubCheckRun?.commit ?? null,
expiresAt,
contextId,
});
}
@ -507,8 +545,8 @@ export class SchemaPublisher {
targetId: target.id,
schemaVersionId: latestVersion?.version ?? null,
isSuccess: true,
breakingSchemaChanges: null,
safeSchemaChanges: checkResult.state?.schemaChanges ?? null,
breakingSchemaChanges: checkResult.state?.schemaChanges?.breaking ?? null,
safeSchemaChanges: checkResult.state?.schemaChanges?.safe ?? null,
schemaPolicyWarnings: checkResult.state?.schemaPolicyWarnings ?? null,
schemaPolicyErrors: null,
schemaCompositionErrors: null,
@ -534,6 +572,7 @@ export class SchemaPublisher {
: null,
githubSha: githubCheckRun?.commit ?? null,
expiresAt,
contextId,
});
}
@ -545,9 +584,9 @@ export class SchemaPublisher {
target,
organization,
conclusion: checkResult.conclusion,
changes: checkResult.state?.schemaChanges ?? null,
changes: checkResult.state?.schemaChanges?.all ?? null,
breakingChanges: checkResult.state?.schemaChanges?.breaking ?? null,
warnings: checkResult.state?.schemaPolicyWarnings ?? null,
breakingChanges: null,
compositionErrors: null,
errors: null,
schemaCheckId: schemaCheck?.id ?? null,
@ -588,10 +627,10 @@ export class SchemaPublisher {
return {
__typename: 'SchemaCheckSuccess',
valid: true,
changes: checkResult.state?.schemaChanges ?? [],
changes: checkResult.state?.schemaChanges?.all ?? [],
warnings: checkResult.state?.schemaPolicyWarnings ?? [],
initial: latestVersion == null,
schemaCheck: toGraphQLSchemaCheck(schemaCheckSelector, inflateSchemaCheck(schemaCheck)),
schemaCheck: toGraphQLSchemaCheck(schemaCheckSelector, schemaCheck),
} as const;
}
@ -600,17 +639,16 @@ export class SchemaPublisher {
return {
__typename: 'SchemaCheckError',
valid: false,
changes: [
...(checkResult.state.schemaChanges?.breaking ?? []),
...(checkResult.state.schemaChanges?.safe ?? []),
],
changes: checkResult.state.schemaChanges?.all ?? [],
warnings: checkResult.state.schemaPolicy?.warnings ?? [],
errors: [
...(checkResult.state.schemaChanges?.breaking ?? []),
...(checkResult.state.schemaChanges?.breaking?.filter(
breaking => breaking.approvalMetadata == null && breaking.isSafeBasedOnUsage === false,
) ?? []),
...(checkResult.state.schemaPolicy?.errors?.map(formatPolicyError) ?? []),
...(checkResult.state.composition.errors ?? []),
],
schemaCheck: toGraphQLSchemaCheck(schemaCheckSelector, inflateSchemaCheck(schemaCheck)),
schemaCheck: toGraphQLSchemaCheck(schemaCheckSelector, schemaCheck),
} as const;
}
@ -1423,8 +1461,8 @@ export class SchemaPublisher {
};
conclusion: SchemaCheckConclusion;
warnings: SchemaCheckWarning[] | null;
changes: Array<Change> | null;
breakingChanges: Array<Change> | null;
changes: Array<SchemaChangeType> | null;
breakingChanges: Array<SchemaChangeType> | null;
compositionErrors: Array<{
message: string;
}> | null;
@ -1620,7 +1658,7 @@ export class SchemaPublisher {
initial: boolean;
force?: boolean | null;
valid: boolean;
changes: Array<Change>;
changes: Array<SchemaChangeType>;
errors: readonly Types.SchemaError[];
messages?: string[];
detailsUrl: string | null;
@ -1698,7 +1736,7 @@ export class SchemaPublisher {
].join('\n');
}
private changesToMarkdown(changes: ReadonlyArray<Change>): string {
private changesToMarkdown(changes: ReadonlyArray<SchemaChangeType>): string {
const breakingChanges = changes.filter(filterChangesByLevel(CriticalityLevel.Breaking));
const dangerousChanges = changes.filter(filterChangesByLevel(CriticalityLevel.Dangerous));
const safeChanges = changes.filter(filterChangesByLevel(CriticalityLevel.NonBreaking));
@ -1729,10 +1767,14 @@ export class SchemaPublisher {
}
function filterChangesByLevel(level: CriticalityLevel) {
return (change: Change) => change.criticality.level === level;
return (change: SchemaChangeType) => change.criticality === level;
}
function writeChanges(type: string, changes: ReadonlyArray<Change>, lines: string[]): void {
function writeChanges(
type: string,
changes: ReadonlyArray<{ message: string }>,
lines: string[],
): void {
if (changes.length > 0) {
lines.push(
...['', `### ${type} changes`].concat(
@ -1766,3 +1808,12 @@ function tryPrettifySDL(sdl: string): string {
}
const millisecondsPerDay = 60 * 60 * 24 * 1000;
const SchemaCheckContextIdModel = z
.string()
.min(1, {
message: 'Context ID must be at least 1 character long.',
})
.max(200, {
message: 'Context ID cannot exceed length of 200 characters.',
});

View file

@ -21,6 +21,9 @@ import {
} from 'graphql';
import { parseResolveInfo } from 'graphql-parse-resolve-info';
import { z } from 'zod';
import { CriticalityLevel } from '@graphql-inspector/core';
import { SchemaChangeType } from '@hive/storage';
import type * as Types from '../../__generated__/types';
import { ProjectType, type DateRange } from '../../shared/entities';
import { createPeriod, parseDateRangeInput, PromiseOrValue } from '../../shared/helpers';
import type {
@ -59,13 +62,12 @@ import {
type SuperGraphInformation,
} from './lib/federation-super-graph';
import { stripUsedSchemaCoordinatesFromDocumentNode } from './lib/unused-graphql';
import { Inspector, toGraphQLSchemaChange } from './providers/inspector';
import { Inspector } from './providers/inspector';
import { SchemaBuildError } from './providers/orchestrators/errors';
import { detectUrlChanges } from './providers/registry-checks';
import { ensureSDL, SchemaHelper } from './providers/schema-helper';
import { SchemaManager } from './providers/schema-manager';
import { SchemaPublisher } from './providers/schema-publisher';
import { schemaChangeFromMeta, SerializableChange } from './schema-change-from-meta';
import { toGraphQLSchemaCheck, toGraphQLSchemaCheckCurry } from './to-graphql-schema-check';
const MaybeModel = <T extends z.ZodType>(value: T) => z.union([z.null(), z.undefined(), value]);
@ -169,7 +171,7 @@ export const resolvers: SchemaModule.Resolvers = {
if ('changes' in result && result.changes) {
return {
...result,
changes: result.changes.map(toGraphQLSchemaChange),
changes: result.changes,
errors:
result.errors?.map(error => ({
...error,
@ -257,7 +259,7 @@ export const resolvers: SchemaModule.Resolvers = {
if ('changes' in result) {
return {
...result,
changes: result.changes?.map(toGraphQLSchemaChange),
changes: result.changes,
};
}
@ -296,7 +298,7 @@ export const resolvers: SchemaModule.Resolvers = {
return {
...result,
changes: result.changes?.map(toGraphQLSchemaChange),
changes: result.changes,
errors: result.errors?.map(error => ({
...error,
path: 'path' in error ? error.path?.split('.') : null,
@ -586,7 +588,7 @@ export const resolvers: SchemaModule.Resolvers = {
),
])
.then(async ([before, after]) => {
let changes: SerializableChange[] = [];
const changes: SchemaChangeType[] = [];
if (before) {
const previousSchema = buildSortedSchemaFromSchemaObject(
@ -607,19 +609,10 @@ export const resolvers: SchemaModule.Resolvers = {
}`,
),
);
const diffChanges = await injector.get(Inspector).diff(previousSchema, currentSchema);
changes = diffChanges.map(change => ({
...change,
isSafeBasedOnUsage: change.criticality.isSafeBasedOnUsage ?? false,
}));
changes.push(...(await injector.get(Inspector).diff(previousSchema, currentSchema)));
}
changes.push(
...detectUrlChanges(schemasBefore, schemasAfter).map(change => ({
...change,
isSafeBasedOnUsage: false,
})),
);
changes.push(...detectUrlChanges(schemasBefore, schemasAfter));
const result: SchemaCompareResult = {
result: {
@ -1212,9 +1205,7 @@ export const resolvers: SchemaModule.Resolvers = {
return source.result.schemas.before === null;
},
async changes(source) {
return source.result.changes.map(change =>
toGraphQLSchemaChange(schemaChangeFromMeta(change)),
);
return source.result.changes;
},
diff(source) {
const { before, current } = source.result.schemas;
@ -1281,6 +1272,23 @@ export const resolvers: SchemaModule.Resolvers = {
},
},
SchemaChangeConnection: createConnection(),
SchemaChange: {
message: (change, args) => {
return args.withSafeBasedOnUsageNote && change.isSafeBasedOnUsage === true
? `${change.message} (non-breaking based on usage)`
: change.message;
},
path: change => change.path?.split('.') ?? null,
criticality: change => criticalityMap[change.criticality],
criticalityReason: change => change.reason,
approval: change => change.approvalMetadata,
isSafeBasedOnUsage: change => change.isSafeBasedOnUsage,
},
SchemaChangeApproval: {
approvedBy: (approval, _, { injector }) =>
injector.get(SchemaManager).getUserForSchemaChangeById({ userId: approval.userId }),
approvedAt: approval => approval.date,
},
SchemaErrorConnection: createConnection(),
SchemaWarningConnection: createConnection(),
SchemaCheckSuccess: {
@ -2117,14 +2125,14 @@ export const resolvers: SchemaModule.Resolvers = {
return null;
}
return schemaCheck.safeSchemaChanges.map(toGraphQLSchemaChange);
return schemaCheck.safeSchemaChanges;
},
breakingSchemaChanges(schemaCheck) {
if (!schemaCheck.breakingSchemaChanges) {
return null;
}
return schemaCheck.breakingSchemaChanges.map(toGraphQLSchemaChange);
return schemaCheck.breakingSchemaChanges;
},
webUrl(schemaCheck, _, { injector }) {
return injector.get(SchemaManager).getSchemaCheckWebUrl({
@ -2159,14 +2167,14 @@ export const resolvers: SchemaModule.Resolvers = {
return null;
}
return schemaCheck.safeSchemaChanges.map(toGraphQLSchemaChange);
return schemaCheck.safeSchemaChanges;
},
breakingSchemaChanges(schemaCheck) {
if (!schemaCheck.breakingSchemaChanges) {
return null;
}
return schemaCheck.breakingSchemaChanges.map(toGraphQLSchemaChange);
return schemaCheck.breakingSchemaChanges;
},
compositionErrors(schemaCheck) {
return schemaCheck.schemaCompositionErrors;
@ -2358,3 +2366,9 @@ function transformGraphQLScalarType(entity: GraphQLScalarType): GraphQLScalarTyp
description: entity.description,
};
}
const criticalityMap: Record<CriticalityLevel, Types.CriticalityLevel> = {
[CriticalityLevel.Breaking]: 'Breaking',
[CriticalityLevel.NonBreaking]: 'Safe',
[CriticalityLevel.Dangerous]: 'Dangerous',
};

View file

@ -1,6 +1,6 @@
import lodash from 'lodash';
import { SchemaCheck } from '@hive/storage';
import type { FailedSchemaCheckMapper, SuccessfulSchemaCheckMapper } from '../../shared/mappers';
import type { InflatedSchemaCheck } from './providers/schema-manager';
const { curry } = lodash;
/**
@ -8,7 +8,7 @@ const { curry } = lodash;
*/
export function toGraphQLSchemaCheck(
selector: { organizationId: string; projectId: string },
schemaCheck: InflatedSchemaCheck,
schemaCheck: SchemaCheck,
): SuccessfulSchemaCheckMapper | FailedSchemaCheckMapper {
if (schemaCheck.isSuccess) {
return {

View file

@ -1,7 +1,7 @@
import { Injectable } from 'graphql-modules';
import { Change } from '@graphql-inspector/core';
import type { PolicyConfigurationObject } from '@hive/policy';
import type {
SchemaChangeType,
SchemaCheck,
SchemaCheckInput,
SchemaCompositionError,
@ -40,7 +40,6 @@ import type {
import type { OrganizationAccessScope } from '../../auth/providers/organization-access';
import type { ProjectAccessScope } from '../../auth/providers/project-access';
import type { TargetAccessScope } from '../../auth/providers/target-access';
import { SerializableChange } from '../../schema/schema-change-from-meta';
type Paginated<T> = T & {
after?: string | null;
@ -368,7 +367,7 @@ export interface Storage {
serviceName: string;
composable: boolean;
actionFn(): Promise<void>;
changes: Array<Change> | null;
changes: Array<SchemaChangeType> | null;
} & TargetSelector &
(
| {
@ -396,7 +395,7 @@ export interface Storage {
logIds: string[];
base_schema: string | null;
actionFn(): Promise<void>;
changes: Array<Change>;
changes: Array<SchemaChangeType>;
previousSchemaVersion: null | string;
github: null | {
repository: string;
@ -422,7 +421,7 @@ export interface Storage {
* If it return `null` the schema version does not have any changes persisted.
* This can happen if the schema version was created before we introduced persisting changes.
*/
getSchemaChangesForVersion(_: { versionId: string }): Promise<null | Array<SerializableChange>>;
getSchemaChangesForVersion(_: { versionId: string }): Promise<null | Array<SchemaChangeType>>;
updateVersionStatus(
_: {
@ -679,11 +678,12 @@ export interface Storage {
purgeExpiredSchemaChecks(_: { expiresAt: Date }): Promise<{
deletedSchemaCheckCount: number;
deletedSdlStoreCount: number;
deletedSchemaChangeApprovalCount: number;
}>;
/**
* Find schema check for a given ID and target.
*/
findSchemaCheck(input: { schemaCheckId: string; targetId: string }): Promise<SchemaCheck | null>;
findSchemaCheck(input: { schemaCheckId: string }): Promise<SchemaCheck | null>;
/**
* Retrieve paginated schema checks for a given target.
*/
@ -721,6 +721,14 @@ export interface Storage {
userId: string;
}): Promise<SchemaCheck | null>;
/**
* Retrieve approved schema changes for a given context.
*/
getApprovedSchemaChangesForContextId(args: {
targetId: string;
contextId: string;
}): Promise<Array<SchemaChangeType>>;
getTargetBreadcrumbForTargetId(_: { targetId: string }): Promise<TargetBreadcrumb | null>;
/**

View file

@ -1,15 +1,9 @@
import type { DocumentNode, GraphQLSchema } from 'graphql';
import type {
ClientStatsValues,
OperationStatsValues,
SchemaChange,
SchemaError,
} from '../__generated__/types';
import { type SuperGraphInformation } from '../modules/schema/lib/federation-super-graph';
import { SchemaCheckWarning } from '../modules/schema/providers/models/shared';
import { SchemaBuildError } from '../modules/schema/providers/orchestrators/errors';
import { InflatedSchemaCheck } from '../modules/schema/providers/schema-manager';
import { SerializableChange } from '../modules/schema/schema-change-from-meta';
import type { SchemaChangeType, SchemaCheck, SchemaCheckApprovalMetadata } from '@hive/storage';
import type { ClientStatsValues, OperationStatsValues, SchemaError } from '../__generated__/types';
import type { SuperGraphInformation } from '../modules/schema/lib/federation-super-graph';
import type { SchemaCheckWarning } from '../modules/schema/providers/models/shared';
import type { SchemaBuildError } from '../modules/schema/providers/orchestrators/errors';
import type {
ActivityObject,
DateRange,
@ -27,7 +21,7 @@ import type {
Token,
User,
} from './entities';
import { type PromiseOrValue } from './helpers';
import type { PromiseOrValue } from './helpers';
export interface SchemaVersion extends SchemaVersionEntity {
project: string;
@ -234,7 +228,9 @@ export type GraphQLScalarTypeMapper = WithSchemaCoordinatesUsage<{
};
}>;
export type SchemaChangeConnection = ReadonlyArray<SchemaChange>;
export type SchemaChangeConnection = ReadonlyArray<SchemaChangeType>;
export type SchemaChange = SchemaChangeType;
export type SchemaChangeApproval = SchemaCheckApprovalMetadata;
export type SchemaErrorConnection = readonly SchemaError[];
export type SchemaWarningConnection = readonly SchemaCheckWarning[];
export type UserConnection = readonly User[];
@ -268,7 +264,7 @@ export type SchemaCompareResult = {
before: string | null;
current: string;
};
changes: Array<SerializableChange>;
changes: Array<SchemaChangeType>;
versionIds: {
before: string | null;
current: string;
@ -355,13 +351,14 @@ export type FailedSchemaCheckMapper = {
organizationId: string;
projectId: string;
};
} & Extract<InflatedSchemaCheck, { isSuccess: false }>;
} & Extract<SchemaCheck, { isSuccess: false }>;
export type SuccessfulSchemaCheckMapper = {
__typename: 'SuccessfulSchemaCheck';
selector: {
organizationId: string;
projectId: string;
};
} & Extract<InflatedSchemaCheck, { isSuccess: true }>;
} & Extract<SchemaCheck, { isSuccess: true }>;
export type SchemaPolicyWarningConnectionMapper = ReadonlyArray<SchemaCheckWarning>;

View file

@ -116,6 +116,12 @@ export async function main() {
);
transaction.setMeasurement('deletedSchemaCheckCount', result.deletedSchemaCheckCount, '');
transaction.setMeasurement('deletedSdlStoreCount', result.deletedSdlStoreCount, '');
transaction.setMeasurement(
'deletedSchemaChangeApprovals',
result.deletedSchemaChangeApprovalCount,
'',
);
transaction.finish();
} catch (error) {
captureException(error);

View file

@ -16,6 +16,7 @@
"db:generate": "schemats generate --config schemats.cjs -o src/db/types.ts && prettier --write src/db/types.ts"
},
"devDependencies": {
"@graphql-inspector/core": "5.0.1",
"@sentry/node": "7.80.1",
"@sentry/types": "7.80.1",
"@tgriesser/schemats": "9.0.1",
@ -23,6 +24,7 @@
"@types/node": "18.18.9",
"@types/pg": "8.10.9",
"dotenv": "16.3.1",
"fast-json-stable-stringify": "2.1.0",
"got": "12.6.1",
"param-case": "3.0.4",
"pg-promise": "11.5.4",

View file

@ -157,10 +157,19 @@ export interface projects {
validation_url: string | null;
}
export interface schema_change_approvals {
context_id: string;
created_at: Date;
schema_change: any;
schema_change_id: string;
target_id: string;
}
export interface schema_checks {
breaking_schema_changes: any | null;
composite_schema_sdl: string | null;
composite_schema_sdl_store_id: string | null;
context_id: string | null;
created_at: Date;
expires_at: Date | null;
github_check_run_id: string | null;
@ -318,6 +327,7 @@ export interface DBTables {
organizations: organizations;
organizations_billing: organizations_billing;
projects: projects;
schema_change_approvals: schema_change_approvals;
schema_checks: schema_checks;
schema_log: schema_log;
schema_policy_config: schema_policy_config;

View file

@ -1,14 +1,13 @@
import type { SerializableChange } from 'packages/services/api/src/modules/schema/schema-change-from-meta';
import {
DatabasePool,
DatabaseTransactionConnection,
SerializableValue,
sql,
TaggedTemplateLiteralInvocation,
UniqueIntegrityConstraintViolationError,
} from 'slonik';
import { update } from 'slonik-utilities';
import zod from 'zod';
import type { Change } from '@graphql-inspector/core';
import type {
ActivityObject,
Alert,
@ -56,12 +55,14 @@ import {
users,
} from './db';
import {
SchemaChangeModel,
HiveSchemaChangeModel,
SchemaCheckModel,
SchemaCompositionError,
SchemaCompositionErrorModel,
SchemaPolicyWarningModel,
TargetBreadcrumbModel,
type SchemaChangeType,
type SchemaCheckApprovalMetadata,
type SchemaCompositionError,
} from './schema-change-model';
import type { Slonik } from './shared';
@ -657,17 +658,27 @@ export async function createStorage(connection: string, maximumPoolSize: number)
"external_auth_user_id" = ${auth0UserId}
`);
},
async getUserById({ id }) {
const user = await pool.maybeOne<Slonik<users>>(
sql`SELECT * FROM public.users WHERE id = ${id} LIMIT 1`,
);
getUserById: batch(async input => {
const userIds = input.map(i => i.id);
const users = await pool.any<Slonik<users>>(sql`
SELECT
*
FROM
public.users
WHERE
id = ANY(${sql.array(userIds, 'uuid')})
`);
if (user) {
return transformUser(user);
const mappings = new Map<string, Slonik<users>>();
for (const user of users) {
mappings.set(user.id, user);
}
return null;
},
return userIds.map(id => {
const user = mappings.get(id) ?? null;
return Promise.resolve(user ? transformUser(user) : null);
});
}),
async updateUser({ id, displayName, fullName }) {
return transformUser(
await pool.one<Slonik<users>>(sql`
@ -2102,8 +2113,7 @@ export async function createStorage(connection: string, maximumPoolSize: number)
return null;
}
// TODO: I don't like the cast...
return changes.rows.map(row => SchemaChangeModel.parse(row) as SerializableChange);
return changes.rows.map(row => HiveSchemaChangeModel.parse(row));
},
async updateVersionStatus({ version, valid }) {
@ -3386,55 +3396,57 @@ export async function createStorage(connection: string, maximumPoolSize: number)
await Promise.all(sdlStoreInserts);
return trx.one<{ id: string }>(sql`
INSERT INTO "public"."schema_checks" (
"schema_sdl_store_id"
, "service_name"
, "meta"
, "target_id"
, "schema_version_id"
, "is_success"
, "schema_composition_errors"
, "breaking_schema_changes"
, "safe_schema_changes"
, "schema_policy_warnings"
, "schema_policy_errors"
, "composite_schema_sdl_store_id"
, "supergraph_sdl_store_id"
, "is_manually_approved"
, "manual_approval_user_id"
, "github_check_run_id"
, "github_repository"
, "github_sha"
, "expires_at"
)
VALUES (
${args.schemaSDLHash}
, ${args.serviceName}
, ${jsonify(args.meta)}
, ${args.targetId}
, ${args.schemaVersionId}
, ${args.isSuccess}
, ${jsonify(args.schemaCompositionErrors)}
, ${jsonify(args.breakingSchemaChanges?.map(toSerializableSchemaChange))}
, ${jsonify(args.safeSchemaChanges?.map(toSerializableSchemaChange))}
, ${jsonify(args.schemaPolicyWarnings?.map(w => SchemaPolicyWarningModel.parse(w)))}
, ${jsonify(args.schemaPolicyErrors?.map(w => SchemaPolicyWarningModel.parse(w)))}
, ${args.compositeSchemaSDLHash}
, ${args.supergraphSDLHash}
, ${args.isManuallyApproved}
, ${args.manualApprovalUserId}
, ${args.githubCheckRunId}
, ${args.githubRepository}
, ${args.githubSha}
, ${args.expiresAt?.toISOString() ?? null}
)
RETURNING id
INSERT INTO "public"."schema_checks" (
"schema_sdl_store_id"
, "service_name"
, "meta"
, "target_id"
, "schema_version_id"
, "is_success"
, "schema_composition_errors"
, "breaking_schema_changes"
, "safe_schema_changes"
, "schema_policy_warnings"
, "schema_policy_errors"
, "composite_schema_sdl_store_id"
, "supergraph_sdl_store_id"
, "is_manually_approved"
, "manual_approval_user_id"
, "github_check_run_id"
, "github_repository"
, "github_sha"
, "expires_at"
, "context_id"
)
VALUES (
${args.schemaSDLHash}
, ${args.serviceName}
, ${jsonify(args.meta)}
, ${args.targetId}
, ${args.schemaVersionId}
, ${args.isSuccess}
, ${jsonify(args.schemaCompositionErrors)}
, ${jsonify(args.breakingSchemaChanges?.map(toSerializableSchemaChange))}
, ${jsonify(args.safeSchemaChanges?.map(toSerializableSchemaChange))}
, ${jsonify(args.schemaPolicyWarnings?.map(w => SchemaPolicyWarningModel.parse(w)))}
, ${jsonify(args.schemaPolicyErrors?.map(w => SchemaPolicyWarningModel.parse(w)))}
, ${args.compositeSchemaSDLHash}
, ${args.supergraphSDLHash}
, ${args.isManuallyApproved}
, ${args.manualApprovalUserId}
, ${args.githubCheckRunId}
, ${args.githubRepository}
, ${args.githubSha}
, ${args.expiresAt?.toISOString() ?? null}
, ${args.contextId}
)
RETURNING
"id"
`);
});
const check = await this.findSchemaCheck({
schemaCheckId: result.id,
targetId: args.targetId,
});
if (!check) {
@ -3454,7 +3466,6 @@ export async function createStorage(connection: string, maximumPoolSize: number)
LEFT JOIN "public"."sdl_store" as s_supergraph ON s_supergraph."id" = c."supergraph_sdl_store_id"
WHERE
c."id" = ${args.schemaCheckId}
AND c."target_id" = ${args.targetId}
`);
if (result == null) {
@ -3464,6 +3475,52 @@ export async function createStorage(connection: string, maximumPoolSize: number)
return SchemaCheckModel.parse(result);
},
async approveFailedSchemaCheck(args) {
const schemaCheck = await this.findSchemaCheck({
schemaCheckId: args.schemaCheckId,
});
if (schemaCheck?.breakingSchemaChanges == null) {
return null;
}
// We enhance the approved schema checks with some metadata
const approvalMetadata: SchemaCheckApprovalMetadata = {
userId: args.userId,
date: new Date().toISOString(),
schemaCheckId: schemaCheck.id,
};
if (schemaCheck.contextId !== null) {
// Try to approve and claim all the breaking schema changes for this context
await pool.query(sql`
INSERT INTO "public"."schema_change_approvals" (
"target_id"
, "context_id"
, "schema_change_id"
, "schema_change"
)
VALUES ${sql.join(
schemaCheck.breakingSchemaChanges.map(
change =>
sql`(
${schemaCheck.targetId}
, ${schemaCheck.contextId}
, ${change.id}
, ${sql.jsonb(
toSerializableSchemaChange({
...change,
// We enhance the approved schema changes with some metadata that can be displayed on the UI
approvalMetadata,
}),
)}
)`,
),
sql`,`,
)}
ON CONFLICT ("target_id", "context_id", "schema_change_id") DO NOTHING
`);
}
const updateResult = await pool.maybeOne<{
id: string;
}>(sql`
@ -3473,11 +3530,23 @@ export async function createStorage(connection: string, maximumPoolSize: number)
"is_success" = true
, "is_manually_approved" = true
, "manual_approval_user_id" = ${args.userId}
, "breaking_schema_changes" = (
SELECT json_agg(
CASE
WHEN COALESCE(jsonb_typeof("change"->'approvalMetadata'), 'null') = 'null'
THEN jsonb_set("change", '{approvalMetadata}', ${sql.jsonb(approvalMetadata)})
ELSE "change"
END
)
FROM jsonb_array_elements("breaking_schema_changes") AS "change"
)
WHERE
"id" = ${args.schemaCheckId}
AND "is_success" = false
AND "schema_composition_errors" IS NULL
RETURNING id
RETURNING
"id",
"breaking_schema_changes"
`);
if (updateResult == null) {
@ -3498,6 +3567,19 @@ export async function createStorage(connection: string, maximumPoolSize: number)
return SchemaCheckModel.parse(result);
},
async getApprovedSchemaChangesForContextId(args) {
const result = await pool.anyFirst<unknown>(sql`
SELECT
"schema_change"
FROM
"public"."schema_change_approvals"
WHERE
"target_id" = ${args.targetId}
AND "context_id" = ${args.contextId}
`);
return result.map(record => HiveSchemaChangeModel.parse(record));
},
async getPaginatedSchemaChecksForTarget(args) {
let cursor: null | {
createdAt: string;
@ -3673,43 +3755,75 @@ export async function createStorage(connection: string, maximumPoolSize: number)
1000
)
RETURNING
"schema_sdl_store_id" as "id1",
"supergraph_sdl_store_id" as "id2",
"composite_schema_sdl_store_id" as "id3"
"schema_sdl_store_id" as "storeId1",
"supergraph_sdl_store_id" as "storeId2",
"composite_schema_sdl_store_id" as "storeId3",
"target_id" as "targetId",
"context_id" as "contextId"
`);
const ids = PurgeExpiredSchemaChecksIDModel.parse(result);
if (ids.size === 0) {
return {
deletedSchemaCheckCount: result.length,
deletedSdlStoreCount: 0,
};
const { storeIds, targetIds, contextIds } = PurgeExpiredSchemaChecksIDModel.parse(result);
let deletedSdlStoreCount = 0;
let deletedSchemaChangeApprovalCount = 0;
if (storeIds.size !== 0) {
const deletedSdlStoreRecords = await pool.any<unknown>(sql`
DELETE
FROM
"sdl_store"
WHERE
"id" = ANY(
${sql.array(Array.from(storeIds), 'text')}
)
AND NOT EXISTS (
SELECT
1
FROM
"schema_checks"
WHERE
"schema_checks"."schema_sdl_store_id" = "sdl_store"."id"
OR "schema_checks"."composite_schema_sdl_store_id" = "sdl_store"."id"
OR "schema_checks"."supergraph_sdl_store_id" = "sdl_store"."id"
)
RETURNING
true as "d"
`);
deletedSdlStoreCount = deletedSdlStoreRecords.length;
}
if (targetIds.size && contextIds.size) {
const deletedSchemaChangeApprovals = await pool.any<unknown>(sql`
DELETE
FROM
"schema_change_approvals"
WHERE
"target_id" = ANY(
${sql.array(Array.from(targetIds), 'text')}
)
AND "context_id" = ANY(
${sql.array(Array.from(contextIds), 'text')}
)
AND NOT EXISTS (
SELECT
1
FROM "schema_checks"
WHERE
"schema_checks"."target_id" = "schema_change_approvals"."target_id"
AND "schema_checks"."context_id" = "schema_change_approvals"."context_id"
)
RETURNING
true as "d"
`);
deletedSchemaChangeApprovalCount = deletedSchemaChangeApprovals.length;
}
const deletedRecords = await pool.any<unknown>(sql`
DELETE
FROM
"sdl_store"
WHERE
"id" = ANY(
${sql.array(Array.from(ids), 'text')}
)
AND NOT EXISTS (
SELECT
1
FROM
"schema_checks"
WHERE
"schema_checks"."schema_sdl_store_id" = "sdl_store"."id"
OR "schema_checks"."composite_schema_sdl_store_id" = "sdl_store"."id"
OR "schema_checks"."supergraph_sdl_store_id" = "sdl_store"."id"
)
RETURNING
true as "d"
`);
return {
deletedSchemaCheckCount: result.length,
deletedSdlStoreCount: deletedRecords.length,
deletedSdlStoreCount,
deletedSchemaChangeApprovalCount,
};
});
},
@ -3966,7 +4080,7 @@ const DocumentCollectionDocumentModel = zod.object({
async function insertSchemaVersionChanges(
trx: DatabaseTransactionConnection,
args: {
changes: Array<Change>;
changes: Array<SchemaChangeType>;
versionId: string;
},
) {
@ -3988,9 +4102,9 @@ async function insertSchemaVersionChanges(
sql`(
${args.versionId},
${change.type},
${change.criticality.level},
${change.criticality},
${JSON.stringify(change.meta)}::jsonb,
${change.criticality.isSafeBasedOnUsage ?? false}
${change.isSafeBasedOnUsage ?? false}
)`,
),
sql`\n,`,
@ -4069,21 +4183,23 @@ function jsonify<T>(obj: T | null | undefined) {
/**
* Utility function for stripping a schema change of its computable properties for efficient storage in the database.
*/
function toSerializableSchemaChange(change: {
function toSerializableSchemaChange(change: SchemaChangeType): {
id: string;
type: string;
criticality?: {
isSafeBasedOnUsage?: boolean;
meta: Record<string, SerializableValue>;
approvalMetadata: null | {
userId: string;
date: string;
schemaCheckId: string;
};
meta: unknown;
}): {
type: string;
meta: unknown;
isSafeBasedOnUsage: boolean;
} {
return {
id: change.id,
type: change.type,
meta: change.meta,
isSafeBasedOnUsage: change.criticality?.isSafeBasedOnUsage ?? false,
isSafeBasedOnUsage: change.isSafeBasedOnUsage,
approvalMetadata: change.approvalMetadata,
};
}
@ -4109,6 +4225,7 @@ const schemaCheckSQLFields = sql`
, c."github_sha" as "githubSha"
, coalesce(c."is_manually_approved", false) as "isManuallyApproved"
, c."manual_approval_user_id" as "manualApprovalUserId"
, c."context_id" as "contextId"
`;
const schemaVersionSQLFields = (t = sql``) => sql`
@ -4145,19 +4262,37 @@ const TargetModel = zod.object({
const PurgeExpiredSchemaChecksIDModel = zod
.array(
zod.object({
id1: zod.string().nullable(),
id2: zod.string().nullable(),
id3: zod.string().nullable(),
storeId1: zod.string().nullable(),
storeId2: zod.string().nullable(),
storeId3: zod.string().nullable(),
targetId: zod.string(),
contextId: zod.string().nullable(),
}),
)
.transform(items => {
const ids = new Set<string>();
const storeIds = new Set<string>();
const targetIds = new Set<string>();
const contextIds = new Set<string>();
for (const row of items) {
row.id1 && ids.add(row.id1);
row.id2 && ids.add(row.id2);
row.id3 && ids.add(row.id3);
row.storeId1 && storeIds.add(row.storeId1);
row.storeId2 && storeIds.add(row.storeId2);
row.storeId3 && storeIds.add(row.storeId3);
if (row.contextId) {
targetIds.add(row.targetId);
contextIds.add(row.contextId);
}
}
return ids;
return {
storeIds,
targetIds,
contextIds,
};
});
export * from './schema-change-model';
export {
buildRegistryServiceURLFromMeta,
type RegistryServiceUrlChangeSerializableChange,
} from './schema-change-meta';

View file

@ -93,7 +93,9 @@ export type RegistryServiceUrlChangeChange = RegistryServiceUrlChangeSerializabl
/**
* Create the schema change from the persisted meta data.
*/
function schemaChangeFromSerializableChange(change: SerializableChange): Change {
export function schemaChangeFromSerializableChange(
change: SerializableChange,
): Change | RegistryServiceUrlChangeChange {
switch (change.type) {
case ChangeType.FieldArgumentDescriptionChanged:
return fieldArgumentDescriptionChangedFromMeta(change);
@ -204,7 +206,7 @@ function schemaChangeFromSerializableChange(change: SerializableChange): Change
}
}
function buildRegistryServiceURLFromMeta(
export function buildRegistryServiceURLFromMeta(
change: RegistryServiceUrlChangeSerializableChange,
): RegistryServiceUrlChangeChange {
return {
@ -223,22 +225,3 @@ function buildRegistryServiceURLFromMeta(
meta: change.meta,
} as const;
}
export function schemaChangeFromMeta(serializableChange: SerializableChange): Change {
const change = schemaChangeFromSerializableChange(serializableChange);
// see https://github.com/kamilkisiela/graphql-inspector/blob/3f5d7291d730119c926a05d165aa2f4a309e4fbd/packages/core/src/diff/rules/consider-usage.ts#L71-L78
if (serializableChange.isSafeBasedOnUsage) {
return {
...change,
criticality: {
...change.criticality,
level: CriticalityLevel.Dangerous,
isSafeBasedOnUsage: true,
},
message: `${change.message} (non-breaking based on usage)`,
};
}
return change;
}

View file

@ -1,6 +1,9 @@
/** These mirror DB models from */
import type { RegistryServiceUrlChangeSerializableChange } from 'packages/services/api/src/modules/schema/schema-change-from-meta';
import crypto from 'node:crypto';
import stableJSONStringify from 'fast-json-stable-stringify';
import { SerializableValue } from 'slonik';
import { z } from 'zod';
import { CriticalityLevel } from '@graphql-inspector/core';
import type {
ChangeType,
DirectiveAddedChange,
@ -57,6 +60,10 @@ import type {
UnionMemberAddedChange,
UnionMemberRemovedChange,
} from '@graphql-inspector/core';
import {
RegistryServiceUrlChangeSerializableChange,
schemaChangeFromSerializableChange,
} from './schema-change-meta';
// prettier-ignore
const FieldArgumentDescriptionChangedLiteral = z.literal("FIELD_ARGUMENT_DESCRIPTION_CHANGED" satisfies `${ChangeType.FieldArgumentDescriptionChanged}`)
@ -796,10 +803,71 @@ export const SchemaPolicyWarningModel = z.object({
endColumn: z.number().nullable(),
});
const SchemaChangeModelWithIsSafeBreakingChange = z.intersection(
SchemaChangeModel,
z.object({ isSafeBasedOnUsage: z.boolean().optional() }),
);
function createSchemaChangeId(change: { type: string; meta: Record<string, unknown> }): string {
const hash = crypto.createHash('md5');
hash.update(stableJSONStringify(change.meta));
return hash.digest('hex');
}
const ApprovalMetadataModel = z.object({
userId: z.string(),
schemaCheckId: z.string(),
date: z.string(),
});
export type SchemaCheckApprovalMetadata = z.TypeOf<typeof ApprovalMetadataModel>;
export const HiveSchemaChangeModel = z
.intersection(
SchemaChangeModel,
z.object({
/** optional property for identifying whether a change is safe based on the usage data. */
isSafeBasedOnUsage: z.boolean().optional(),
/** Optional id that uniquely identifies a change. The ID is generated in case the input does not contain it. */
id: z.string().optional(),
approvalMetadata: ApprovalMetadataModel.nullable()
.optional()
.transform(value => value ?? null),
}),
)
// We inflate the schema check when reading it from the database
// In order to keep TypeScript compiler from blowing up we use our own internal
// type for the schema checks that is no exhaustive union.
// We only do exhaustive json validation when reading from the database
// to make sure there is no inconsistency between runtime types and database types.
.transform(
(
rawChange,
): {
readonly id: string;
readonly type: string;
readonly meta: Record<string, SerializableValue>;
readonly criticality: CriticalityLevel;
readonly reason: string | null;
readonly message: string;
readonly path: string | null;
readonly approvalMetadata: SchemaCheckApprovalMetadata | null;
readonly isSafeBasedOnUsage: boolean;
} => {
const change = schemaChangeFromSerializableChange(rawChange as any);
return {
get id() {
return rawChange.id ?? createSchemaChangeId(change);
},
type: change.type,
approvalMetadata: rawChange.approvalMetadata,
criticality: change.criticality.level,
message: change.message,
meta: change.meta,
path: change.path ?? null,
isSafeBasedOnUsage: rawChange.isSafeBasedOnUsage ?? false,
reason: change.criticality.reason ?? null,
};
},
);
export type SchemaChangeType = z.TypeOf<typeof HiveSchemaChangeModel>;
// Schema Checks
@ -833,8 +901,8 @@ const SchemaCheckSharedPolicyFields = {
};
const SchemaCheckSharedChangesFields = {
safeSchemaChanges: z.array(SchemaChangeModelWithIsSafeBreakingChange).nullable(),
breakingSchemaChanges: z.array(SchemaChangeModelWithIsSafeBreakingChange).nullable(),
safeSchemaChanges: z.array(HiveSchemaChangeModel).nullable(),
breakingSchemaChanges: z.array(HiveSchemaChangeModel).nullable(),
};
const ManuallyApprovedSchemaCheckFields = {
@ -864,6 +932,7 @@ const SchemaCheckSharedOutputFields = {
// we need to improve the model code to reflect that
githubRepository: z.string().nullable(),
githubSha: z.string().nullable(),
contextId: z.string().nullable(),
};
const SchemaCheckSharedInputFields = {

View file

@ -192,18 +192,36 @@ const ActiveSchemaCheck_SchemaCheckFragment = graphql(`
}
breakingSchemaChanges {
nodes {
message
message(withSafeBasedOnUsageNote: false)
criticality
criticalityReason
path
approval {
approvedBy {
id
displayName
}
approvedAt
schemaCheckId
}
isSafeBasedOnUsage
}
}
safeSchemaChanges {
nodes {
message
message(withSafeBasedOnUsageNote: false)
criticality
criticalityReason
path
approval {
approvedBy {
id
displayName
}
approvedAt
schemaCheckId
}
isSafeBasedOnUsage
}
}
schemaPolicyWarnings {

View file

@ -1,9 +1,13 @@
import { ReactElement } from 'react';
import { ReactElement, useMemo } from 'react';
import Link from 'next/link';
import { clsx } from 'clsx';
import { format } from 'date-fns';
import { CheckIcon } from 'lucide-react';
import reactStringReplace from 'react-string-replace';
import { Label } from '@/components/common';
import { Tooltip } from '@/components/v2';
import { CriticalityLevel, SchemaChangeFieldsFragment } from '@/graphql';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
export function labelize(message: string) {
// Turn " into '
@ -42,6 +46,13 @@ export function ChangesBlock(props: {
<MaybeWrapTooltip tooltip={change.criticalityReason ?? null}>
<span className="text-gray-600 dark:text-white">{labelize(change.message)}</span>
</MaybeWrapTooltip>
{change.isSafeBasedOnUsage ? (
<span className="cursor-pointer text-yellow-500">
{' '}
<CheckIcon className="inline h-3 w-3" /> Safe based on usage data
</span>
) : null}
{change.approval ? <SchemaChangeApproval approval={change.approval} /> : null}
</li>
))}
</ul>
@ -49,6 +60,57 @@ export function ChangesBlock(props: {
);
}
const SchemaChangeApproval = (props: {
approval: Exclude<SchemaChangeFieldsFragment['approval'], null | undefined>;
}) => {
const approvalName = props.approval.approvedBy?.displayName ?? '<unknown>';
const approvalDate = useMemo(
() => format(new Date(props.approval.approvedAt), 'do MMMM yyyy'),
[props.approval.approvedAt],
);
const route = useRouteSelector();
// eslint-disable-next-line no-restricted-syntax
const schemaCheckPath = useMemo(
() =>
'/' +
[
route.organizationId,
route.projectId,
route.targetId,
'checks',
props.approval.schemaCheckId,
].join('/'),
[],
);
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip
content={
<>
This breaking change was manually{' '}
{props.approval.schemaCheckId === route.schemaCheckId ? (
<>
{' '}
approved by {approvalName} in this check on {approvalDate}.
</>
) : (
<Link href={schemaCheckPath} className="text-orange-500 hover:underline">
approved by {approvalName} on {approvalDate}.
</Link>
)}
</>
}
>
<span className="cursor-pointer text-green-500">
{' '}
<CheckIcon className="inline h-3 w-3" /> Approved by {approvalName}
</span>
</Tooltip>
</Tooltip.Provider>
);
};
function MaybeWrapTooltip(props: { children: React.ReactNode; tooltip: string | null }) {
return props.tooltip ? (
<Tooltip.Provider delayDuration={200}>

View file

@ -90,9 +90,18 @@ fragment SchemaVersionFields on SchemaVersion {
fragment SchemaChangeFields on SchemaChange {
path
message
message(withSafeBasedOnUsageNote: false)
criticality
criticalityReason
approval {
approvedBy {
id
displayName
}
approvedAt
schemaCheckId
}
isSafeBasedOnUsage
}
fragment SchemaCompareResultFields on SchemaCompareResult {

View file

@ -6,18 +6,22 @@ const changes = [
{
message: 'Type "Foo" was removed',
criticality: CriticalityLevel.Breaking,
isSafeBasedOnUsage: false,
},
{
message: 'Input field "limit" was added to input object type "Filter"',
criticality: CriticalityLevel.Breaking,
isSafeBasedOnUsage: false,
},
{
message: 'Field "User.nickname" is no longer deprecated',
criticality: CriticalityLevel.Dangerous,
isSafeBasedOnUsage: false,
},
{
message: 'Field "type" was added to object type "User"',
criticality: CriticalityLevel.Safe,
isSafeBasedOnUsage: false,
},
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View file

@ -131,9 +131,16 @@ Or, multiple files using a `glob` expression:
hive schema:check "src/*.graphql"
```
If you want to leverage from retaining approved breaking changes within the lifecyle of a pull/merge
request or branch, you must provide the `--contextId` parameter. Using `--contextId` is optional
when using GitHub repositories and actions with the `--github` flag.
```bash
hive schema:check --contextId "pr-123" "src/*.graphql"
```
Further reading:
- [`schema:check` API Reference](/docs/api-reference/cli#check-a-schema)
- [Publishing a schema to the Schema Registry](/docs/features/schema-registry#publish-a-schema)
- [Conditional Breaking Changes](/docs/management/targets#conditional-breaking-changes)

View file

@ -175,6 +175,18 @@ For additional reading:
- [Checking a schema using Hive CLI](/docs/api-reference/cli#check-a-schema)
### Approve breaking schema changes
Sometimes, you want to allow a breaking change to be published to the registry. This can be done by
manually approving a failed schema check on the Hive App.
By approving a schema check. You confirm that you are aware of the breaking changes within the
schema check, and want to retain that approval within the context of a pull/merge request or branch
lifecycle.
In order to retain the approval of the breaking changes, additional configuration is required. See
[Checking a schema using Hive CLI](/docs/api-reference/cli#check-a-schema).
### Fetch a schema
Sometimes it is useful to fetch a schema (SDL or Supergraph) from Hive, for example, to use it in a

View file

@ -0,0 +1,37 @@
---
title: Breaking change approval improvements
description: Approved breaking changes are now remembered in the context of a pull request/branch.
date: 2023-11-16
authors: [laurin]
---
import { Callout } from 'nextra-theme-docs'
Breaking change approvals are now retained in the context of a pull request/branch.
In the schema check details, we now display the user that approved a breaking chang.
![Approved by annotation](/changelog/2023-11-16-schema-check-breaking-change-approval-context/approved-by.png)
Also, approvals and breaking changes that are safe based on usage data are now always shown within
the breaking changes section instead of being moved to the safe changes section.
<Callout>
Benefiting from this feature requires **upgrading to the `@graphql-hive/cli` version `0.31.0`**.
</Callout>
If you use GitHub repositories and GitHub actions via the `--github` flag, no further action is
required, aside from upgrading the CLI.
For other CI providers, you need to update your CI configuration to pass the `--contextId` option
for the `hive schema:check` command. We recommend to use the pull/merge request number or branch
name as the context ID.
```bash
hive schema:check --contextId "pull-request-21" ./my-schema.graphql
```
More Information:
- [Approve breaking schema change](/docs/features/schema-registry#approve-breaking-schema-changes)
- [Checking a schema using Hive CLI](/docs/api-reference/cli#check-a-schema)

View file

@ -1139,6 +1139,9 @@ importers:
packages/services/storage:
devDependencies:
'@graphql-inspector/core':
specifier: 5.0.1
version: 5.0.1(graphql@16.8.1)
'@sentry/node':
specifier: 7.80.1
version: 7.80.1
@ -1160,6 +1163,9 @@ importers:
dotenv:
specifier: 16.3.1
version: 16.3.1
fast-json-stable-stringify:
specifier: 2.1.0
version: 2.1.0
got:
specifier: 12.6.1
version: 12.6.1

View file

@ -18,8 +18,9 @@
"emitDecoratorMetadata": true,
"sourceMap": true,
"declaration": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"resolveJsonModule": false,
"moduleResolution": "node",
"strict": true,