mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat: remember breaking change approvals in context of a pull request (#3359)
This commit is contained in:
parent
19b0c042a7
commit
21d246d815
48 changed files with 1668 additions and 505 deletions
21
.changeset/eleven-bears-agree.md
Normal file
21
.changeset/eleven-bears-agree.md
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -452,12 +452,14 @@ export function initSeed() {
|
|||
author: string;
|
||||
commit: string;
|
||||
},
|
||||
contextId?: string,
|
||||
) {
|
||||
return await checkSchema(
|
||||
{
|
||||
sdl,
|
||||
service,
|
||||
meta,
|
||||
contextId,
|
||||
},
|
||||
secret,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
"rootDir": "src",
|
||||
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
"rootDir": "src",
|
||||
"target": "es2017",
|
||||
"module": "esnext",
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
"rootDir": "src",
|
||||
"target": "es2017",
|
||||
"module": "esnext",
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
"rootDir": "src",
|
||||
"target": "es2017",
|
||||
"module": "esnext",
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@
|
|||
"emitDecoratorMetadata": true,
|
||||
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"resolveJsonModule": false,
|
||||
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue