Skip to content

Commit 94fbf89

Browse files
committed
feat: complete CLI --forceSafe flag implementation to auto-approve breaking changes
1 parent 0c62149 commit 94fbf89

File tree

15 files changed

+520
-17
lines changed

15 files changed

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

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)