-
Notifications
You must be signed in to change notification settings - Fork 255
feat: add proposal status command #3164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
14b1716
f7b717e
90e6951
d2dc1cd
5f04b76
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; | ||
| import type { Proposal } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; | ||
| import { Command } from 'commander'; | ||
| import pc from 'picocolors'; | ||
| import { getBaseHeaders } from '../../../core/config.js'; | ||
| import { BaseCommandOptions } from '../../../core/types/types.js'; | ||
|
|
||
| const PROPOSALS_PAGE_SIZE = 50; | ||
|
|
||
| type FindProposalResult = { success: true; proposal: Proposal } | { success: false; details: string }; | ||
|
|
||
| const findProposal = async ( | ||
| client: BaseCommandOptions['client'], | ||
| { | ||
| name, | ||
| federatedGraphName, | ||
| namespace, | ||
| checkAllProposals, | ||
| }: { | ||
| name: string; | ||
| federatedGraphName: string; | ||
| namespace?: string; | ||
| checkAllProposals: boolean; | ||
| }, | ||
| ): Promise<FindProposalResult> => { | ||
| let offset = 0; | ||
|
|
||
| do { | ||
| const resp = await client.platform.getProposalsByFederatedGraph( | ||
| { | ||
| federatedGraphName, | ||
| namespace, | ||
| limit: PROPOSALS_PAGE_SIZE, | ||
| offset, | ||
| }, | ||
| { | ||
| headers: getBaseHeaders(), | ||
| }, | ||
| ); | ||
|
|
||
| if (resp.response?.code !== EnumStatusCode.OK) { | ||
| return { | ||
| success: false, | ||
| details: resp.response?.details || `Failed to fetch proposal '${name}'.`, | ||
| }; | ||
| } | ||
|
|
||
| const proposal = resp.proposals.find((item) => item.name === name); | ||
| if (proposal) { | ||
| return { success: true, proposal }; | ||
| } | ||
|
|
||
| if (!checkAllProposals) { | ||
| break; | ||
| } | ||
|
|
||
| if (resp.proposals.length === 0) { | ||
| break; | ||
| } | ||
|
|
||
| offset += resp.proposals.length; | ||
| if (offset >= resp.totalCount) { | ||
| break; | ||
| } | ||
| } while (true); | ||
|
|
||
| return { | ||
| success: false, | ||
| details: `Proposal '${name}' not found.`, | ||
| }; | ||
| }; | ||
|
|
||
| export default (opts: BaseCommandOptions) => { | ||
| const command = new Command('status'); | ||
| command.description( | ||
| 'Checks the status of an existing proposal for a federated graph. By default, only the 50 most recent proposals are checked. Checking all proposals can be slow.', | ||
| ); | ||
| command.argument('<name>', 'The name of the proposal to check.'); | ||
| command.requiredOption( | ||
| '-f, --federation-graph <federatedGraphName>', | ||
| 'The name of the federated graph this proposal is for.', | ||
| ); | ||
| command.option('-n, --namespace [string]', 'The namespace of the federated graph.'); | ||
| command.option('-j, --json', 'Prints to the console in json format instead of text.'); | ||
| command.option( | ||
| '--check-all-proposals', | ||
| 'Checks all proposals instead of only the 50 most recent proposals. This can be slow.', | ||
| ); | ||
|
|
||
| command.action(async (name, options) => { | ||
| let result: FindProposalResult; | ||
|
|
||
| try { | ||
| result = await findProposal(opts.client, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't add But in most cases, network failures are handled by checking |
||
| name, | ||
| federatedGraphName: options.federationGraph, | ||
| namespace: options.namespace, | ||
| checkAllProposals: options.checkAllProposals, | ||
| }); | ||
| } catch (error) { | ||
| result = { | ||
| success: false, | ||
| details: error instanceof Error ? error.message : 'An unknown error occurred.', | ||
| }; | ||
| } | ||
|
|
||
| if (!result.success) { | ||
| if (options.json) { | ||
| console.log( | ||
| JSON.stringify({ | ||
| status: 'error', | ||
| details: result.details, | ||
| }), | ||
| ); | ||
| } else { | ||
| console.error(pc.red(result.details)); | ||
| } | ||
| process.exitCode = 1; | ||
| return; | ||
| } | ||
|
|
||
| if (options.json) { | ||
| console.log( | ||
| JSON.stringify({ | ||
| status: result.proposal.state, | ||
| }), | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| console.log(`Proposal '${name}' status is ${pc.bold(result.proposal.state)}.`); | ||
| }); | ||
|
|
||
| return command; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| import { type MessageInitShape } from '@bufbuild/protobuf'; | ||
| import { createClient, createRouterTransport } from '@connectrpc/connect'; | ||
| import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; | ||
| import { | ||
| type GetProposalsByFederatedGraphRequest, | ||
| GetProposalsByFederatedGraphResponseSchema, | ||
| PlatformService, | ||
| } 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 StatusProposalCommand from '../src/commands/proposal/commands/status.js'; | ||
| import { Client } from '../src/core/client/client.js'; | ||
|
|
||
| type GetProposalsResponse = MessageInitShape<typeof GetProposalsByFederatedGraphResponseSchema>; | ||
|
|
||
| function createMockTransport( | ||
| response: GetProposalsResponse | ((req: GetProposalsByFederatedGraphRequest) => GetProposalsResponse), | ||
| onGetProposals?: (req: GetProposalsByFederatedGraphRequest) => void, | ||
| ) { | ||
| return createRouterTransport(({ service }) => { | ||
| service(PlatformService, { | ||
| getProposalsByFederatedGraph: (req) => { | ||
| onGetProposals?.(req); | ||
| return typeof response === 'function' ? response(req) : response; | ||
| }, | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| async function runStatus( | ||
| response: GetProposalsResponse | ((req: GetProposalsByFederatedGraphRequest) => GetProposalsResponse), | ||
| args: string[] = [], | ||
| onGetProposals?: (req: GetProposalsByFederatedGraphRequest) => void, | ||
| ): Promise<void> { | ||
| const client: Client = { | ||
| platform: createClient(PlatformService, createMockTransport(response, onGetProposals)), | ||
| }; | ||
| const program = new Command(); | ||
| program.exitOverride(); | ||
| program.addCommand(StatusProposalCommand({ client })); | ||
| await program.parseAsync(['status', 'my-proposal', '--federation-graph', 'my-graph', ...args], { from: 'user' }); | ||
| } | ||
|
|
||
| describe('proposal status', () => { | ||
| let logSpy: MockInstance<typeof console.log>; | ||
| let errorSpy: MockInstance<typeof console.error>; | ||
|
|
||
| beforeEach(() => { | ||
| logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); | ||
| errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.exitCode = undefined; | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| test('prints the proposal status as text', async () => { | ||
| let request: GetProposalsByFederatedGraphRequest | undefined; | ||
|
|
||
| await runStatus( | ||
| { | ||
| response: { code: EnumStatusCode.OK }, | ||
| proposals: [{ name: 'my-proposal', state: 'DRAFT' }], | ||
| totalCount: 1, | ||
| }, | ||
| ['--namespace', 'production'], | ||
| (req) => { | ||
| request = req; | ||
| }, | ||
| ); | ||
|
|
||
| expect(request).toMatchObject({ | ||
| federatedGraphName: 'my-graph', | ||
| namespace: 'production', | ||
| limit: 50, | ||
| offset: 0, | ||
| }); | ||
| expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Proposal 'my-proposal' status is DRAFT.")); | ||
|
Check failure on line 79 in cli/test/proposal-status.test.ts
|
||
| expect(errorSpy).not.toHaveBeenCalled(); | ||
| expect(process.exitCode).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('prints the proposal status as json', async () => { | ||
| await runStatus( | ||
| { | ||
| response: { code: EnumStatusCode.OK }, | ||
| proposals: [{ name: 'my-proposal', state: 'APPROVED' }], | ||
| totalCount: 1, | ||
| }, | ||
| ['--json'], | ||
| ); | ||
|
|
||
| expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ status: 'APPROVED' })); | ||
| expect(errorSpy).not.toHaveBeenCalled(); | ||
| expect(process.exitCode).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('only checks the 50 most recent proposals by default', async () => { | ||
| let requestCount = 0; | ||
|
|
||
| await runStatus( | ||
| { | ||
| response: { code: EnumStatusCode.OK }, | ||
| proposals: [{ name: 'another-proposal', state: 'DRAFT' }], | ||
| totalCount: 51, | ||
| }, | ||
| ['--json'], | ||
| () => { | ||
| requestCount++; | ||
| }, | ||
| ); | ||
|
|
||
| expect(requestCount).toBe(1); | ||
| expect(process.exitCode).toBe(1); | ||
| }); | ||
|
|
||
| test('checks subsequent pages when --check-all-proposals is set', async () => { | ||
| const requestedOffsets: number[] = []; | ||
|
|
||
| await runStatus( | ||
| (req) => { | ||
| if (req.offset === 0) { | ||
| return { | ||
| response: { code: EnumStatusCode.OK }, | ||
| proposals: [{ name: 'another-proposal', state: 'DRAFT' }], | ||
| totalCount: 2, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| response: { code: EnumStatusCode.OK }, | ||
| proposals: [{ name: 'my-proposal', state: 'CLOSED' }], | ||
| totalCount: 2, | ||
| }; | ||
| }, | ||
| ['--check-all-proposals', '--json'], | ||
| (req) => { | ||
| requestedOffsets.push(req.offset); | ||
| }, | ||
| ); | ||
|
|
||
| expect(requestedOffsets).toEqual([0, 1]); | ||
| expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ status: 'CLOSED' })); | ||
| expect(errorSpy).not.toHaveBeenCalled(); | ||
| expect(process.exitCode).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('prints a json error and sets a non-zero exit code when the proposal is not found', async () => { | ||
| await runStatus( | ||
| { | ||
| response: { code: EnumStatusCode.OK }, | ||
| proposals: [], | ||
| totalCount: 0, | ||
| }, | ||
| ['--json'], | ||
| ); | ||
|
|
||
| expect(JSON.parse(String(logSpy.mock.calls[0][0]))).toEqual({ | ||
| status: 'error', | ||
| details: "Proposal 'my-proposal' not found.", | ||
| }); | ||
| expect(errorSpy).not.toHaveBeenCalled(); | ||
| expect(process.exitCode).toBe(1); | ||
| }); | ||
|
|
||
| test('prints the control-plane error and sets a non-zero exit code', async () => { | ||
| await runStatus({ | ||
| response: { | ||
| code: EnumStatusCode.ERR_NOT_FOUND, | ||
| details: 'Federated graph my-graph not found', | ||
| }, | ||
| proposals: [], | ||
| totalCount: 0, | ||
| }); | ||
|
|
||
| expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Federated graph my-graph not found')); | ||
| expect(logSpy).not.toHaveBeenCalled(); | ||
| expect(process.exitCode).toBe(1); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| --- | ||
| title: 'Status' | ||
| description: 'Checks the status of an existing proposal for a federated graph.' | ||
| icon: magnifying-glass | ||
| --- | ||
|
|
||
| ## Usage | ||
|
|
||
| ```bash | ||
| npx wgc proposal status <name> --federation-graph <federated-graph-name> [options] | ||
| ``` | ||
|
|
||
| ## Description | ||
|
|
||
| The `npx wgc proposal status` command checks the current status of a proposal. By default, the command searches only the 50 most recent proposals for the federated graph. | ||
|
|
||
| Use `--check-all-proposals` to continue searching through all available proposal pages. Checking all proposals can be slow when the federated graph has many proposals. | ||
|
|
||
| ## **Parameters** | ||
|
|
||
| - `[name]`: The name of the proposal whose status you want to check. | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| ## **Options** | ||
|
|
||
| - `-f, --federation-graph <federatedGraphName>` (required): The name of the federated graph this proposal is for. | ||
|
|
||
| - `-n, --namespace [namespace]`: The namespace of the federated graph (Default: "default"). | ||
|
|
||
| - `-j, --json`: Print the result in JSON format instead of text. | ||
|
|
||
| - `--check-all-proposals`: Search all proposal pages instead of only the 50 most recent proposals. This can be slow. | ||
|
|
||
| ## **Examples** | ||
|
|
||
| ### Check the 50 most recent proposals | ||
|
|
||
| ```bash | ||
| npx wgc proposal status product-changes --federation-graph my-graph | ||
| ``` | ||
|
|
||
| ### Check all proposals | ||
|
|
||
| ```bash | ||
| npx wgc proposal status product-changes --federation-graph my-graph --check-all-proposals | ||
| ``` | ||
|
|
||
| ### Print JSON output | ||
|
|
||
| ```bash | ||
| npx wgc proposal status product-changes --federation-graph my-graph --json | ||
| ``` | ||
Uh oh!
There was an error while loading. Please reload this page.