Skip to content

Commit 543de17

Browse files
feat: complete --forceSafe implementation to auto-approve breaking changes (#7193)
1 parent fe669c9 commit 543de17

File tree

17 files changed

+541
-14
lines changed

17 files changed

+541
-14
lines changed

.changeset/calm-berries-build.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'hive': patch
3+
'@graphql-hive/cli': patch
4+
---
5+
6+
`schema:check --forceSafe` now properly approves breaking schema changes in Hive (requires write permission registry token)

integration-tests/tests/api/schema/check.spec.ts

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2360,3 +2360,272 @@ test.concurrent(
23602360
});
23612361
},
23622362
);
2363+
2364+
test.concurrent(
2365+
'approve failed schema check with author field using target access token',
2366+
async ({ expect }) => {
2367+
const { createOrg } = await initSeed().createOwner();
2368+
const { createProject, organization } = await createOrg();
2369+
const { createTargetAccessToken, project, target } = await createProject(ProjectType.Single);
2370+
const writeToken = await createTargetAccessToken({});
2371+
2372+
const publishResult = await writeToken
2373+
.publishSchema({
2374+
sdl: /* GraphQL */ `
2375+
type Query {
2376+
ping: String
2377+
}
2378+
`,
2379+
})
2380+
.then(r => r.expectNoGraphQLErrors());
2381+
2382+
expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
2383+
2384+
const readToken = await createTargetAccessToken({
2385+
mode: 'readWrite',
2386+
});
2387+
2388+
const checkResult = await readToken
2389+
.checkSchema(/* GraphQL */ `
2390+
type Query {
2391+
ping: Float
2392+
}
2393+
`)
2394+
.then(r => r.expectNoGraphQLErrors());
2395+
2396+
const check = checkResult.schemaCheck;
2397+
if (check.__typename !== 'SchemaCheckError') {
2398+
throw new Error(`Expected SchemaCheckError, got ${check.__typename}`);
2399+
}
2400+
2401+
const schemaCheckId = check.schemaCheck?.id;
2402+
if (schemaCheckId == null) {
2403+
throw new Error('Missing schema check id.');
2404+
}
2405+
2406+
const mutationResult = await execute({
2407+
document: graphql(/* GraphQL */ `
2408+
mutation ApproveFailedSchemaCheckWithAuthor($input: ApproveFailedSchemaCheckInput!) {
2409+
approveFailedSchemaCheck(input: $input) {
2410+
ok {
2411+
schemaCheck {
2412+
__typename
2413+
... on SuccessfulSchemaCheck {
2414+
isApproved
2415+
approvalComment
2416+
cliApprovalMetadata {
2417+
displayName
2418+
email
2419+
}
2420+
}
2421+
}
2422+
}
2423+
error {
2424+
message
2425+
}
2426+
}
2427+
}
2428+
`),
2429+
variables: {
2430+
input: {
2431+
organizationSlug: organization.slug,
2432+
projectSlug: project.slug,
2433+
targetSlug: target.slug,
2434+
schemaCheckId,
2435+
comment: 'Check force approved automatically via CLI --forceSafe flag',
2436+
author: 'John Doe <john@example.com>',
2437+
},
2438+
},
2439+
authToken: readToken.secret,
2440+
}).then(r => r.expectNoGraphQLErrors());
2441+
2442+
expect(mutationResult.approveFailedSchemaCheck.ok).not.toBeNull();
2443+
expect(mutationResult.approveFailedSchemaCheck.error).toBeNull();
2444+
2445+
const approvedCheck = mutationResult.approveFailedSchemaCheck.ok?.schemaCheck;
2446+
expect(approvedCheck?.__typename).toBe('SuccessfulSchemaCheck');
2447+
2448+
if (approvedCheck?.__typename === 'SuccessfulSchemaCheck') {
2449+
expect(approvedCheck.isApproved).toBe(true);
2450+
2451+
expect(approvedCheck.cliApprovalMetadata).toMatchObject({
2452+
displayName: 'John Doe',
2453+
email: 'john@example.com',
2454+
});
2455+
}
2456+
2457+
const schemaCheckQueryResult = await execute({
2458+
document: graphql(/* GraphQL */ `
2459+
query GetSchemaCheckWithApproval(
2460+
$organizationSlug: String!
2461+
$projectSlug: String!
2462+
$targetSlug: String!
2463+
$schemaCheckId: ID!
2464+
) {
2465+
target(
2466+
reference: {
2467+
bySelector: {
2468+
organizationSlug: $organizationSlug
2469+
projectSlug: $projectSlug
2470+
targetSlug: $targetSlug
2471+
}
2472+
}
2473+
) {
2474+
schemaCheck(id: $schemaCheckId) {
2475+
__typename
2476+
... on SuccessfulSchemaCheck {
2477+
id
2478+
isApproved
2479+
cliApprovalMetadata {
2480+
displayName
2481+
email
2482+
}
2483+
breakingSchemaChanges {
2484+
nodes {
2485+
message
2486+
approval {
2487+
cliApprovalMetadata {
2488+
displayName
2489+
email
2490+
}
2491+
}
2492+
}
2493+
}
2494+
}
2495+
}
2496+
}
2497+
}
2498+
`),
2499+
variables: {
2500+
organizationSlug: organization.slug,
2501+
projectSlug: project.slug,
2502+
targetSlug: target.slug,
2503+
schemaCheckId,
2504+
},
2505+
authToken: readToken.secret,
2506+
}).then(r => r.expectNoGraphQLErrors());
2507+
2508+
const queriedCheck = schemaCheckQueryResult.target?.schemaCheck;
2509+
expect(queriedCheck?.__typename).toBe('SuccessfulSchemaCheck');
2510+
2511+
if (queriedCheck?.__typename === 'SuccessfulSchemaCheck') {
2512+
expect(queriedCheck.isApproved).toBe(true);
2513+
2514+
const breakingChanges = queriedCheck.breakingSchemaChanges?.nodes ?? [];
2515+
expect(breakingChanges.length).toBeGreaterThan(0);
2516+
2517+
for (const change of breakingChanges) {
2518+
expect(change.approval?.cliApprovalMetadata).toMatchObject({
2519+
displayName: 'John Doe',
2520+
email: 'john@example.com',
2521+
});
2522+
}
2523+
}
2524+
},
2525+
);
2526+
2527+
test.concurrent(
2528+
'approve failed schema check handles different author formats',
2529+
async ({ expect }) => {
2530+
const testCases = [
2531+
{
2532+
author: 'john@example.com',
2533+
expected: { displayName: 'john@example.com', email: 'john@example.com' },
2534+
description: 'email only',
2535+
},
2536+
{
2537+
author: 'John Doe',
2538+
expected: { displayName: 'John Doe', email: '<no email provided>' },
2539+
description: 'name only',
2540+
},
2541+
{
2542+
author: 'John Doe <john@example.com>',
2543+
expected: { displayName: 'John Doe', email: 'john@example.com' },
2544+
description: 'git standard format',
2545+
},
2546+
];
2547+
2548+
for (const testCase of testCases) {
2549+
const { createOrg } = await initSeed().createOwner();
2550+
const { createProject, organization } = await createOrg();
2551+
const { createTargetAccessToken, project, target } = await createProject(ProjectType.Single);
2552+
2553+
const writeToken = await createTargetAccessToken({
2554+
mode: 'readWrite',
2555+
});
2556+
2557+
await writeToken
2558+
.publishSchema({
2559+
sdl: /* GraphQL */ `
2560+
type Query {
2561+
ping: String
2562+
email: String
2563+
}
2564+
`,
2565+
})
2566+
.then(r => r.expectNoGraphQLErrors());
2567+
2568+
const checkResult = await writeToken
2569+
.checkSchema(/* GraphQL */ `
2570+
type Query {
2571+
ping: String
2572+
}
2573+
`)
2574+
.then(r => r.expectNoGraphQLErrors());
2575+
2576+
expect(checkResult.schemaCheck.__typename).toBe('SchemaCheckError');
2577+
if (checkResult.schemaCheck.__typename !== 'SchemaCheckError') {
2578+
throw new Error('Expected SchemaCheckError');
2579+
}
2580+
2581+
const schemaCheckId = checkResult.schemaCheck.schemaCheck?.id;
2582+
expect(schemaCheckId).toBeDefined();
2583+
2584+
const mutationResult = await execute({
2585+
document: graphql(/* GraphQL */ `
2586+
mutation ApproveFailedSchemaCheckWithDifferentAuthorFormats(
2587+
$input: ApproveFailedSchemaCheckInput!
2588+
) {
2589+
approveFailedSchemaCheck(input: $input) {
2590+
ok {
2591+
schemaCheck {
2592+
__typename
2593+
... on SuccessfulSchemaCheck {
2594+
isApproved
2595+
approvalComment
2596+
cliApprovalMetadata {
2597+
displayName
2598+
email
2599+
}
2600+
}
2601+
}
2602+
}
2603+
error {
2604+
message
2605+
}
2606+
}
2607+
}
2608+
`),
2609+
variables: {
2610+
input: {
2611+
organizationSlug: organization.slug,
2612+
projectSlug: project.slug,
2613+
targetSlug: target.slug,
2614+
schemaCheckId: schemaCheckId!,
2615+
comment: `Testing ${testCase.description}`,
2616+
author: testCase.author,
2617+
},
2618+
},
2619+
authToken: writeToken.secret,
2620+
}).then(r => r.expectNoGraphQLErrors());
2621+
2622+
expect(mutationResult.approveFailedSchemaCheck.ok).not.toBeNull();
2623+
2624+
const approvedCheck = mutationResult.approveFailedSchemaCheck.ok?.schemaCheck;
2625+
if (approvedCheck?.__typename === 'SuccessfulSchemaCheck') {
2626+
expect(approvedCheck.cliApprovalMetadata?.displayName).toBe(testCase.expected.displayName);
2627+
expect(approvedCheck.cliApprovalMetadata?.email).toBe(testCase.expected.email);
2628+
}
2629+
}
2630+
},
2631+
);

integration-tests/tests/cli/schema.spec.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
/* eslint-disable no-process-env */
22
import { createHash } from 'node:crypto';
3-
import { ProjectType } from 'testkit/gql/graphql';
3+
import { ProjectType, RuleInstanceSeverityLevel } from 'testkit/gql/graphql';
44
import * as GraphQLSchema from 'testkit/gql/graphql';
55
import type { CompositeSchema } from '@hive/api/__generated__/types';
66
import { createCLI, schemaCheck, schemaPublish } from '../../testkit/cli';
77
import { cliOutputSnapshotSerializer } from '../../testkit/cli-snapshot-serializer';
88
import { initSeed } from '../../testkit/seed';
9+
import { createPolicy } from '../api/policy/policy-check.spec';
910

1011
expect.addSnapshotSerializer(cliOutputSnapshotSerializer);
1112

@@ -903,3 +904,73 @@ test('schema:publish with `--target` flag succeeds for organization access token
903904
ℹ Available at http://__URL__
904905
`);
905906
});
907+
908+
test.concurrent(
909+
'schema:check --forceSafe auto-approves breaking changes using target access token',
910+
async ({ expect }) => {
911+
const { createOrg } = await initSeed().createOwner();
912+
const { createProject, organization } = await createOrg();
913+
const { createTargetAccessToken, project, target } = await createProject(ProjectType.Single);
914+
915+
const writeToken = await createTargetAccessToken({
916+
mode: 'readWrite',
917+
});
918+
919+
await schemaPublish([
920+
'--registry.accessToken',
921+
writeToken.secret,
922+
'--commit',
923+
'abc123',
924+
'fixtures/init-schema.graphql',
925+
]);
926+
927+
await expect(
928+
schemaCheck([
929+
'--registry.accessToken',
930+
writeToken.secret,
931+
'--commit',
932+
'def456',
933+
'--forceSafe',
934+
'--target',
935+
`${organization.slug}/${project.slug}/${target.slug}`,
936+
'fixtures/breaking-schema.graphql',
937+
]),
938+
).resolves.toContain('Breaking changes were expected (forced)');
939+
},
940+
);
941+
942+
test.concurrent(
943+
'schema:check --forceSafe fails when schema policy errors prevent approval',
944+
async ({ expect }) => {
945+
const { createOrg } = await initSeed().createOwner();
946+
const { createProject, organization } = await createOrg();
947+
const { createTargetAccessToken, setProjectSchemaPolicy, project, target } =
948+
await createProject(ProjectType.Single);
949+
await setProjectSchemaPolicy(createPolicy(RuleInstanceSeverityLevel.Error));
950+
951+
const writeToken = await createTargetAccessToken({
952+
mode: 'readWrite',
953+
});
954+
955+
await schemaPublish([
956+
'--registry.accessToken',
957+
writeToken.secret,
958+
'--commit',
959+
'abc123',
960+
'fixtures/init-schema.graphql',
961+
]);
962+
963+
await expect(
964+
schemaCheck([
965+
'--registry.accessToken',
966+
writeToken.secret,
967+
'--commit',
968+
'def456',
969+
'--forceSafe',
970+
'--target',
971+
`${organization.slug}/${project.slug}/${target.slug}`,
972+
'fixtures/breaking-schema.graphql',
973+
]),
974+
).rejects.toThrow('Failed to auto-approve: Schema check has schema policy errors');
975+
},
976+
);

0 commit comments

Comments
 (0)