Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions cli/src/commands/proposal/commands/status.ts
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.');
Comment thread
alepane21 marked this conversation as resolved.
Outdated
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, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't add try/catch block here even if agent tells you to. I'll try and document when this is required or not.
We don't really catch exceptions for code that calls RPC because that would make the codebase a bit tangled. Instead the CLI application has a top-level error handling in cli/src/index.ts that we use for catching unexpected errors.

But in most cases, network failures are handled by checking resp.response?.code !== EnumStatusCode.OK. The functions such as findProposal are then wrappers around RPC code and they model a result object with success property.

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;
};
2 changes: 2 additions & 0 deletions cli/src/commands/proposal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Command } from 'commander';
import { BaseCommandOptions } from '../../core/types/types.js';
import { checkAuth } from '../auth/utils.js';
import CreateProposalCommand from './commands/create.js';
import StatusProposalCommand from './commands/status.js';
import UpdateProposalCommand from './commands/update.js';

export default (opts: BaseCommandOptions) => {
const command = new Command('proposal');
command.description('Provides commands for creating and maintaining proposals for a federated graph');
command.addCommand(CreateProposalCommand(opts));
command.addCommand(UpdateProposalCommand(opts));
command.addCommand(StatusProposalCommand(opts));

command.hook('preAction', async (thisCmd) => {
await checkAuth();
Expand Down
181 changes: 181 additions & 0 deletions cli/test/proposal-status.test.ts
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

View workflow job for this annotation

GitHub Actions / build_test_default

test/proposal-status.test.ts > proposal status > prints the proposal status as text

AssertionError: expected "log" to be called with arguments: [ StringContaining{…} ] Received: 1st log call: [ - StringContaining "Proposal 'my-proposal' status is DRAFT.", + "Proposal 'my-proposal' status is DRAFT.", ] Number of calls: 1 ❯ test/proposal-status.test.ts:79:20

Check failure on line 79 in cli/test/proposal-status.test.ts

View workflow job for this annotation

GitHub Actions / build_test_node_matrix (22.x)

test/proposal-status.test.ts > proposal status > prints the proposal status as text

AssertionError: expected "log" to be called with arguments: [ StringContaining{…} ] Received: 1st log call: [ - StringContaining "Proposal 'my-proposal' status is DRAFT.", + "Proposal 'my-proposal' status is DRAFT.", ] Number of calls: 1 ❯ test/proposal-status.test.ts:79:20

Check failure on line 79 in cli/test/proposal-status.test.ts

View workflow job for this annotation

GitHub Actions / build_test_node_matrix (24.x)

test/proposal-status.test.ts > proposal status > prints the proposal status as text

AssertionError: expected "log" to be called with arguments: [ StringContaining{…} ] Received: 1st log call: [ - StringContaining "Proposal 'my-proposal' status is DRAFT.", + "Proposal 'my-proposal' status is DRAFT.", ] Number of calls: 1 ❯ test/proposal-status.test.ts:79:20
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);
});
});
4 changes: 4 additions & 0 deletions docs-website/cli/proposal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ The `proposal` command and its subcommands provide functionality to manage propo
<Card title="Update a proposal" href="/cli/proposal/update" icon="pencil">
Update an existing proposal for a federated graph
</Card>

<Card title="Check proposal status" href="/cli/proposal/status" icon="magnifying-glass">
Check the status of an existing proposal for a federated graph
</Card>
51 changes: 51 additions & 0 deletions docs-website/cli/proposal/status.mdx
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.
Comment thread
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
```
Loading
Loading