From c4bfc592367a7e8adca454002045771c795b7477 Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Fri, 7 Aug 2026 12:44:45 +0200 Subject: [PATCH 1/2] feat: add approve command --- cli/src/commands/proposal/commands/approve.ts | 43 ++++++++ cli/src/commands/proposal/index.ts | 2 + cli/test/approve-proposal.test.ts | 99 +++++++++++++++++++ docs-website/cli/proposal.mdx | 4 + docs-website/cli/proposal/approve.mdx | 39 ++++++++ docs-website/concepts/proposals.mdx | 8 ++ docs-website/docs.json | 2 +- 7 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 cli/src/commands/proposal/commands/approve.ts create mode 100644 cli/test/approve-proposal.test.ts create mode 100644 docs-website/cli/proposal/approve.mdx diff --git a/cli/src/commands/proposal/commands/approve.ts b/cli/src/commands/proposal/commands/approve.ts new file mode 100644 index 0000000000..79da0d9c06 --- /dev/null +++ b/cli/src/commands/proposal/commands/approve.ts @@ -0,0 +1,43 @@ +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { Command } from 'commander'; +import pc from 'picocolors'; +import { getBaseHeaders } from '../../../core/config.js'; +import { BaseCommandOptions } from '../../../core/types/types.js'; + +export default (opts: BaseCommandOptions) => { + const command = new Command('approve'); + command.description('Approves an existing proposal for a federated graph.'); + command.argument('', 'The name of the proposal to approve.'); + command.requiredOption( + '-f, --federation-graph ', + 'The name of the federated graph this proposal is for.', + ); + command.option('-n, --namespace [string]', 'The namespace of the federated graph.', 'default'); + + command.action(async (name, options) => { + const resp = await opts.client.platform.updateProposal( + { + proposalName: name, + federatedGraphName: options.federationGraph, + namespace: options.namespace, + updateAction: { + case: 'state', + value: 'APPROVED', + }, + }, + { + headers: getBaseHeaders(), + }, + ); + + if (resp.response?.code === EnumStatusCode.OK) { + console.log(pc.green(`Proposal '${name}' was approved successfully.`)); + return; + } + + console.error(pc.red(resp.response?.details || `Failed to approve proposal '${name}'.`)); + process.exitCode = 1; + }); + + return command; +}; diff --git a/cli/src/commands/proposal/index.ts b/cli/src/commands/proposal/index.ts index 0483103bad..9bda671316 100644 --- a/cli/src/commands/proposal/index.ts +++ b/cli/src/commands/proposal/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { BaseCommandOptions } from '../../core/types/types.js'; import { checkAuth } from '../auth/utils.js'; +import ApproveProposalCommand from './commands/approve.js'; import CreateProposalCommand from './commands/create.js'; import UpdateProposalCommand from './commands/update.js'; @@ -9,6 +10,7 @@ export default (opts: BaseCommandOptions) => { command.description('Provides commands for creating and maintaining proposals for a federated graph'); command.addCommand(CreateProposalCommand(opts)); command.addCommand(UpdateProposalCommand(opts)); + command.addCommand(ApproveProposalCommand(opts)); command.hook('preAction', async (thisCmd) => { await checkAuth(); diff --git a/cli/test/approve-proposal.test.ts b/cli/test/approve-proposal.test.ts new file mode 100644 index 0000000000..6c0865fe0f --- /dev/null +++ b/cli/test/approve-proposal.test.ts @@ -0,0 +1,99 @@ +import { type MessageInitShape } from '@bufbuild/protobuf'; +import { createClient, createRouterTransport } from '@connectrpc/connect'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { + PlatformService, + type UpdateProposalRequest, + UpdateProposalResponseSchema, +} from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, test, vi, type MockInstance } from 'vitest'; +import ApproveProposalCommand from '../src/commands/proposal/commands/approve.js'; +import { Client } from '../src/core/client/client.js'; + +function createMockTransport( + response: MessageInitShape, + onUpdateProposal?: (req: UpdateProposalRequest) => void, +) { + return createRouterTransport(({ service }) => { + service(PlatformService, { + updateProposal: (req) => { + onUpdateProposal?.(req); + return response; + }, + }); + }); +} + +async function runApprove( + response: MessageInitShape, + args: string[] = [], + onUpdateProposal?: (req: UpdateProposalRequest) => void, +): Promise { + const client: Client = { + platform: createClient(PlatformService, createMockTransport(response, onUpdateProposal)), + }; + const program = new Command(); + program.exitOverride(); + program.addCommand(ApproveProposalCommand({ client })); + await program.parseAsync(['approve', 'my-proposal', '--federation-graph', 'my-graph', ...args], { from: 'user' }); +} + +describe('approve proposal', () => { + let logSpy: MockInstance; + let errorSpy: MockInstance; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.exitCode = undefined; + vi.restoreAllMocks(); + }); + + test('approves a proposal in the default namespace', async () => { + let request: UpdateProposalRequest | undefined; + + await runApprove({ response: { code: EnumStatusCode.OK } }, [], (req) => { + request = req; + }); + + expect(request).toMatchObject({ + proposalName: 'my-proposal', + federatedGraphName: 'my-graph', + namespace: 'default', + updateAction: { + case: 'state', + value: 'APPROVED', + }, + }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Proposal 'my-proposal' was approved successfully.")); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + test('approves a proposal in an explicitly provided namespace', async () => { + let namespace = ''; + + await runApprove({ response: { code: EnumStatusCode.OK } }, ['--namespace', 'production'], (req) => { + namespace = req.namespace; + }); + + expect(namespace).toBe('production'); + }); + + test('prints the control-plane error and sets a non-zero exit code', async () => { + await runApprove({ + response: { + code: EnumStatusCode.ERR_NOT_FOUND, + details: 'Proposal my-proposal not found', + }, + }); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Proposal my-proposal not found')); + expect(logSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/docs-website/cli/proposal.mdx b/docs-website/cli/proposal.mdx index 2d9b3ed15a..84ba27978e 100644 --- a/docs-website/cli/proposal.mdx +++ b/docs-website/cli/proposal.mdx @@ -14,3 +14,7 @@ The `proposal` command and its subcommands provide functionality to manage propo Update an existing proposal for a federated graph + + + Approve an existing proposal from a local or CI environment + diff --git a/docs-website/cli/proposal/approve.mdx b/docs-website/cli/proposal/approve.mdx new file mode 100644 index 0000000000..b1722d2041 --- /dev/null +++ b/docs-website/cli/proposal/approve.mdx @@ -0,0 +1,39 @@ +--- +title: "Approve" +description: "Approves an existing proposal for a federated graph." +icon: check +--- + +## Usage + +```bash +npx wgc proposal approve --federation-graph [options] +``` + +## Description + +The `npx wgc proposal approve` command approves an existing proposal so its proposed subgraph changes can be published. The command is non-interactive, which makes it suitable for CI workflows. + +The API key used by the command must have write access to the federated graph. + +## **Parameters** + +- `[name]`: The name of the proposal to approve. + +## **Options** + +- `-f, --federation-graph ` (required): The name of the federated graph this proposal is for. +- `-n, --namespace [namespace]`: The namespace of the federated graph (Default: `default`). + +## **CI example** + +Configure an API key as a secret in your CI system and expose it as `COSMO_API_KEY` before running the command: + +```bash +export COSMO_API_KEY= +npx wgc proposal approve product-changes \ + --federation-graph my-graph \ + --namespace production +``` + +The command exits with a non-zero status when the proposal cannot be approved, allowing the CI job to fail immediately. diff --git a/docs-website/concepts/proposals.mdx b/docs-website/concepts/proposals.mdx index b361d469b2..a06c81bef9 100644 --- a/docs-website/concepts/proposals.mdx +++ b/docs-website/concepts/proposals.mdx @@ -72,6 +72,14 @@ npx wgc proposal update --federation-graph (--subg **Note**: When updating a proposal, any subgraphs you specify will completely replace the corresponding subgraphs in the existing proposal. The update is not incremental. +### Approving a Proposal + +Use the [`wgc proposal approve`](/cli/proposal/approve) command to approve an existing proposal. The command is non-interactive and can be used in CI with a `COSMO_API_KEY` that has write access to the federated graph: + +```bash +npx wgc proposal approve --federation-graph [--namespace ] +``` + ## Configuring Proposals diff --git a/docs-website/docs.json b/docs-website/docs.json index 1f73eed1ab..f5832ec0fd 100644 --- a/docs-website/docs.json +++ b/docs-website/docs.json @@ -672,7 +672,7 @@ { "group": "Proposal", "icon": "file-circle-plus", - "pages": ["cli/proposal", "cli/proposal/create", "cli/proposal/update"] + "pages": ["cli/proposal", "cli/proposal/create", "cli/proposal/update", "cli/proposal/approve"] } ] } From 33d1676732be390b7b4457e18381e447f6669578 Mon Sep 17 00:00:00 2001 From: Alessandro Pagnin Date: Fri, 7 Aug 2026 19:04:01 +0200 Subject: [PATCH 2/2] feat: change approve to also allow to close proposals --- cli/src/commands/proposal/commands/approve.ts | 43 -------------- .../proposal/commands/update-status.ts | 59 +++++++++++++++++++ cli/src/commands/proposal/index.ts | 4 +- ...test.ts => update-proposal-status.test.ts} | 55 +++++++++++++---- docs-website/cli/proposal.mdx | 4 +- docs-website/cli/proposal/approve.mdx | 39 ------------ docs-website/cli/proposal/update-status.mdx | 52 ++++++++++++++++ docs-website/concepts/proposals.mdx | 6 +- docs-website/docs.json | 2 +- 9 files changed, 161 insertions(+), 103 deletions(-) delete mode 100644 cli/src/commands/proposal/commands/approve.ts create mode 100644 cli/src/commands/proposal/commands/update-status.ts rename cli/test/{approve-proposal.test.ts => update-proposal-status.test.ts} (62%) delete mode 100644 docs-website/cli/proposal/approve.mdx create mode 100644 docs-website/cli/proposal/update-status.mdx diff --git a/cli/src/commands/proposal/commands/approve.ts b/cli/src/commands/proposal/commands/approve.ts deleted file mode 100644 index 79da0d9c06..0000000000 --- a/cli/src/commands/proposal/commands/approve.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; -import { Command } from 'commander'; -import pc from 'picocolors'; -import { getBaseHeaders } from '../../../core/config.js'; -import { BaseCommandOptions } from '../../../core/types/types.js'; - -export default (opts: BaseCommandOptions) => { - const command = new Command('approve'); - command.description('Approves an existing proposal for a federated graph.'); - command.argument('', 'The name of the proposal to approve.'); - command.requiredOption( - '-f, --federation-graph ', - 'The name of the federated graph this proposal is for.', - ); - command.option('-n, --namespace [string]', 'The namespace of the federated graph.', 'default'); - - command.action(async (name, options) => { - const resp = await opts.client.platform.updateProposal( - { - proposalName: name, - federatedGraphName: options.federationGraph, - namespace: options.namespace, - updateAction: { - case: 'state', - value: 'APPROVED', - }, - }, - { - headers: getBaseHeaders(), - }, - ); - - if (resp.response?.code === EnumStatusCode.OK) { - console.log(pc.green(`Proposal '${name}' was approved successfully.`)); - return; - } - - console.error(pc.red(resp.response?.details || `Failed to approve proposal '${name}'.`)); - process.exitCode = 1; - }); - - return command; -}; diff --git a/cli/src/commands/proposal/commands/update-status.ts b/cli/src/commands/proposal/commands/update-status.ts new file mode 100644 index 0000000000..436ad31dc8 --- /dev/null +++ b/cli/src/commands/proposal/commands/update-status.ts @@ -0,0 +1,59 @@ +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { Command, InvalidArgumentError } from 'commander'; +import pc from 'picocolors'; +import { getBaseHeaders } from '../../../core/config.js'; +import { BaseCommandOptions } from '../../../core/types/types.js'; + +const proposalStatuses = ['APPROVED', 'CLOSED'] as const; +type ProposalStatus = (typeof proposalStatuses)[number]; + +const parseProposalStatus = (value: string): ProposalStatus => { + const status = value.toUpperCase(); + if (!proposalStatuses.includes(status as ProposalStatus)) { + throw new InvalidArgumentError('Allowed values are approved and closed.'); + } + return status as ProposalStatus; +}; + +export default (opts: BaseCommandOptions) => { + const command = new Command('update-status'); + command.description('Updates the status of an existing proposal for a federated graph.'); + command.argument('', 'The name of the proposal to update.'); + command.requiredOption( + '-f, --federation-graph ', + 'The name of the federated graph this proposal is for.', + ); + command.requiredOption( + '-s, --status ', + 'The status to set. Allowed values: approved, closed.', + parseProposalStatus, + ); + command.option('-n, --namespace [string]', 'The namespace of the federated graph.', 'default'); + + command.action(async (name, options) => { + const resp = await opts.client.platform.updateProposal( + { + proposalName: name, + federatedGraphName: options.federationGraph, + namespace: options.namespace, + updateAction: { + case: 'state', + value: options.status, + }, + }, + { + headers: getBaseHeaders(), + }, + ); + + if (resp.response?.code === EnumStatusCode.OK) { + console.log(pc.green(`Proposal '${name}' status was updated to ${options.status} successfully.`)); + return; + } + + console.error(pc.red(resp.response?.details || `Failed to update status for proposal '${name}'.`)); + process.exitCode = 1; + }); + + return command; +}; diff --git a/cli/src/commands/proposal/index.ts b/cli/src/commands/proposal/index.ts index 9bda671316..1484d06c68 100644 --- a/cli/src/commands/proposal/index.ts +++ b/cli/src/commands/proposal/index.ts @@ -1,8 +1,8 @@ import { Command } from 'commander'; import { BaseCommandOptions } from '../../core/types/types.js'; import { checkAuth } from '../auth/utils.js'; -import ApproveProposalCommand from './commands/approve.js'; import CreateProposalCommand from './commands/create.js'; +import UpdateProposalStatusCommand from './commands/update-status.js'; import UpdateProposalCommand from './commands/update.js'; export default (opts: BaseCommandOptions) => { @@ -10,7 +10,7 @@ export default (opts: BaseCommandOptions) => { command.description('Provides commands for creating and maintaining proposals for a federated graph'); command.addCommand(CreateProposalCommand(opts)); command.addCommand(UpdateProposalCommand(opts)); - command.addCommand(ApproveProposalCommand(opts)); + command.addCommand(UpdateProposalStatusCommand(opts)); command.hook('preAction', async (thisCmd) => { await checkAuth(); diff --git a/cli/test/approve-proposal.test.ts b/cli/test/update-proposal-status.test.ts similarity index 62% rename from cli/test/approve-proposal.test.ts rename to cli/test/update-proposal-status.test.ts index 6c0865fe0f..8cac18b757 100644 --- a/cli/test/approve-proposal.test.ts +++ b/cli/test/update-proposal-status.test.ts @@ -8,7 +8,7 @@ import { } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; import { Command } from 'commander'; import { afterEach, beforeEach, describe, expect, test, vi, type MockInstance } from 'vitest'; -import ApproveProposalCommand from '../src/commands/proposal/commands/approve.js'; +import UpdateProposalStatusCommand from '../src/commands/proposal/commands/update-status.js'; import { Client } from '../src/core/client/client.js'; function createMockTransport( @@ -25,7 +25,7 @@ function createMockTransport( }); } -async function runApprove( +async function runUpdateStatus( response: MessageInitShape, args: string[] = [], onUpdateProposal?: (req: UpdateProposalRequest) => void, @@ -35,11 +35,16 @@ async function runApprove( }; const program = new Command(); program.exitOverride(); - program.addCommand(ApproveProposalCommand({ client })); - await program.parseAsync(['approve', 'my-proposal', '--federation-graph', 'my-graph', ...args], { from: 'user' }); + const command = UpdateProposalStatusCommand({ client }); + command.exitOverride(); + program.addCommand(command); + await program.parseAsync( + ['update-status', 'my-proposal', '--federation-graph', 'my-graph', '--status', 'approved', ...args], + { from: 'user' }, + ); } -describe('approve proposal', () => { +describe('update proposal status', () => { let logSpy: MockInstance; let errorSpy: MockInstance; @@ -56,7 +61,7 @@ describe('approve proposal', () => { test('approves a proposal in the default namespace', async () => { let request: UpdateProposalRequest | undefined; - await runApprove({ response: { code: EnumStatusCode.OK } }, [], (req) => { + await runUpdateStatus({ response: { code: EnumStatusCode.OK } }, [], (req) => { request = req; }); @@ -69,23 +74,47 @@ describe('approve proposal', () => { value: 'APPROVED', }, }); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Proposal 'my-proposal' was approved successfully.")); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Proposal 'my-proposal' status was updated to APPROVED successfully."), + ); expect(errorSpy).not.toHaveBeenCalled(); expect(process.exitCode).toBeUndefined(); }); - test('approves a proposal in an explicitly provided namespace', async () => { - let namespace = ''; + test('closes a proposal in an explicitly provided namespace', async () => { + let request: UpdateProposalRequest | undefined; + + await runUpdateStatus( + { response: { code: EnumStatusCode.OK } }, + ['--status', 'CLOSED', '--namespace', 'production'], + (req) => { + request = req; + }, + ); - await runApprove({ response: { code: EnumStatusCode.OK } }, ['--namespace', 'production'], (req) => { - namespace = req.namespace; + expect(request).toMatchObject({ + namespace: 'production', + updateAction: { + case: 'state', + value: 'CLOSED', + }, }); + }); + + test('rejects statuses that cannot be set manually', async () => { + let updateCalled = false; + + await expect( + runUpdateStatus({ response: { code: EnumStatusCode.OK } }, ['--status', 'published'], () => { + updateCalled = true; + }), + ).rejects.toThrow('Allowed values are approved and closed.'); - expect(namespace).toBe('production'); + expect(updateCalled).toBe(false); }); test('prints the control-plane error and sets a non-zero exit code', async () => { - await runApprove({ + await runUpdateStatus({ response: { code: EnumStatusCode.ERR_NOT_FOUND, details: 'Proposal my-proposal not found', diff --git a/docs-website/cli/proposal.mdx b/docs-website/cli/proposal.mdx index 84ba27978e..40929a2116 100644 --- a/docs-website/cli/proposal.mdx +++ b/docs-website/cli/proposal.mdx @@ -15,6 +15,6 @@ The `proposal` command and its subcommands provide functionality to manage propo Update an existing proposal for a federated graph - - Approve an existing proposal from a local or CI environment + + Approve or close an existing proposal from a local or CI environment diff --git a/docs-website/cli/proposal/approve.mdx b/docs-website/cli/proposal/approve.mdx deleted file mode 100644 index b1722d2041..0000000000 --- a/docs-website/cli/proposal/approve.mdx +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: "Approve" -description: "Approves an existing proposal for a federated graph." -icon: check ---- - -## Usage - -```bash -npx wgc proposal approve --federation-graph [options] -``` - -## Description - -The `npx wgc proposal approve` command approves an existing proposal so its proposed subgraph changes can be published. The command is non-interactive, which makes it suitable for CI workflows. - -The API key used by the command must have write access to the federated graph. - -## **Parameters** - -- `[name]`: The name of the proposal to approve. - -## **Options** - -- `-f, --federation-graph ` (required): The name of the federated graph this proposal is for. -- `-n, --namespace [namespace]`: The namespace of the federated graph (Default: `default`). - -## **CI example** - -Configure an API key as a secret in your CI system and expose it as `COSMO_API_KEY` before running the command: - -```bash -export COSMO_API_KEY= -npx wgc proposal approve product-changes \ - --federation-graph my-graph \ - --namespace production -``` - -The command exits with a non-zero status when the proposal cannot be approved, allowing the CI job to fail immediately. diff --git a/docs-website/cli/proposal/update-status.mdx b/docs-website/cli/proposal/update-status.mdx new file mode 100644 index 0000000000..fd0d743d63 --- /dev/null +++ b/docs-website/cli/proposal/update-status.mdx @@ -0,0 +1,52 @@ +--- +title: "Update Status" +description: "Updates the status of an existing proposal for a federated graph." +icon: arrows-rotate +--- + +## Usage + +```bash +npx wgc proposal update-status --federation-graph --status [options] +``` + +## Description + +The `npx wgc proposal update-status` command updates an existing proposal's review status. The command is non-interactive, which makes it suitable for CI workflows. + +The API key used by the command must have write access to the federated graph. + +## **Parameters** + +- `[name]`: The name of the proposal to update. + +## **Options** + +- `-f, --federation-graph ` (required): The name of the federated graph this proposal is for. +- `-s, --status ` (required): The status to set. Accepted values are `approved` and `closed`. Values are case-insensitive. +- `-n, --namespace [namespace]`: The namespace of the federated graph (Default: `default`). + +## **CI examples** + +Configure an API key as a secret in your CI system and expose it as `COSMO_API_KEY` before running the command. + +Approve a proposal: + +```bash +export COSMO_API_KEY= +npx wgc proposal update-status product-changes \ + --federation-graph my-graph \ + --namespace production \ + --status approved +``` + +Close a proposal: + +```bash +npx wgc proposal update-status product-changes \ + --federation-graph my-graph \ + --namespace production \ + --status closed +``` + +The command exits with a non-zero status when the proposal status cannot be updated, allowing the CI job to fail immediately. diff --git a/docs-website/concepts/proposals.mdx b/docs-website/concepts/proposals.mdx index a06c81bef9..0278a309d3 100644 --- a/docs-website/concepts/proposals.mdx +++ b/docs-website/concepts/proposals.mdx @@ -72,12 +72,12 @@ npx wgc proposal update --federation-graph (--subg **Note**: When updating a proposal, any subgraphs you specify will completely replace the corresponding subgraphs in the existing proposal. The update is not incremental. -### Approving a Proposal +### Updating a Proposal Status -Use the [`wgc proposal approve`](/cli/proposal/approve) command to approve an existing proposal. The command is non-interactive and can be used in CI with a `COSMO_API_KEY` that has write access to the federated graph: +Use the [`wgc proposal update-status`](/cli/proposal/update-status) command to approve or close an existing proposal. The command is non-interactive and can be used in CI with a `COSMO_API_KEY` that has write access to the federated graph: ```bash -npx wgc proposal approve --federation-graph [--namespace ] +npx wgc proposal update-status --federation-graph --status [--namespace ] ``` ## Configuring Proposals diff --git a/docs-website/docs.json b/docs-website/docs.json index f5832ec0fd..68d047f0e1 100644 --- a/docs-website/docs.json +++ b/docs-website/docs.json @@ -672,7 +672,7 @@ { "group": "Proposal", "icon": "file-circle-plus", - "pages": ["cli/proposal", "cli/proposal/create", "cli/proposal/update", "cli/proposal/approve"] + "pages": ["cli/proposal", "cli/proposal/create", "cli/proposal/update", "cli/proposal/update-status"] } ] }