Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/enum-flag-choices-in-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": patch
---

The help for a generated command now lists the permitted values of a flag that accepts a closed set, such as `qawolf issue update --status` or `qawolf issue find --statuses`. Before, these flags showed no values, and a caller found them only from the error message of a rejected command. The values come from the contract, so they cannot disagree with what the API accepts. A flag that already has help text keeps it, and the values come after it.
5 changes: 5 additions & 0 deletions .changeset/issue-update-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": minor
---

The CLI has a new `qawolf issue update` command. It changes the description, name, priority, or status of an issue that the team owns. Fields that you do not give stay unchanged. The command was absent because an earlier contract gave the input as a union of branches, which has no flat set of fields for the command generator to make flags from. The installed contract gives the input as one object, so the generator makes the command from it like the other issue commands.
1 change: 1 addition & 0 deletions skills/qawolf-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ current branch.
| `qawolf issue create` | write | Create a bug or coverage request issue for the caller's team. Maintenance issues cannot be created through the public API. |
| `qawolf issue find` | read | List the team's bug reports, maintenance reports, or coverage requests, newest first. |
| `qawolf issue get` | read | Get an issue by id. |
| `qawolf issue update` | write | Update an issue owned by the caller's team. Omitted fields remain unchanged. |
| `qawolf run create` | write | Create a run for the selected flows and/or tags in an environment. |
| `qawolf run find` | read | List an environment's recent runs, newest first. |
| `qawolf run get` | read | Get a run's status, per-flow results, and links. |
Expand Down
53 changes: 53 additions & 0 deletions src/core/publicApi/flagSpecs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,59 @@ describe("buildFlagSpecs", () => {
});
});

it("documents an enum field's values when it has no description", () => {
const schema = z.object({ status: z.enum(["pending", "resolved"]) });

const result = buildFlagSpecs(schema);

expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.flags[0]?.description).toBe("One of: pending, resolved");
});

it("appends an enum field's values to its description", () => {
const schema = z.object({
priority: z.enum(["low", "high"]).describe('Defaults to "low".'),
});

const result = buildFlagSpecs(schema);

expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.flags[0]?.description).toBe(
'Defaults to "low". One of: low, high',
);
});

it("documents the values of an array-of-enum field", () => {
const schema = z.object({
statuses: z.array(z.enum(["pending", "paused"])).describe("Statuses."),
});

const result = buildFlagSpecs(schema);

expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.flags[0]?.description).toBe(
"Statuses. One of: pending, paused",
);
});

it("documents the published issue.update enum flags", () => {
const result = buildFlagSpecs(publicContractsV1.issue.update.input);

expect(result.ok).toBe(true);
if (!result.ok) return;
const described = (field: string) =>
result.flags.find((spec) => spec.field === field)?.description;
expect(described("priority")).toBe(
"One of: unprioritized, low, medium, high, urgent",
);
expect(described("status")).toBe(
"One of: pending, inProgress, paused, resolved, canceled, archived",
);
});

it("rejects intersections whose members share a field", () => {
const schema = z
.object({ name: z.string() })
Expand Down
16 changes: 15 additions & 1 deletion src/core/publicApi/flagSpecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ function flagUsage(field: string, kind: FlagKind): string {
return `${name} <value>`;
}

// An enum field accepts nothing but its own values, so --help lists them rather
// than leaving a caller to learn them from a rejected invocation. The wording
// matches what objectShape.ts gives a union discriminator, so generated help
// describes a closed set of choices the same way wherever one appears.
function describeFlag(schema: JsonSchema): string {
const description = schema.description ?? "";
// An array field carries its values on the item schema, so a repeatable flag
// like --statuses documents the same closed set a scalar one does.
const values = schema.enum ?? schema.items?.enum;
if (!values?.length) return description;
const choices = `One of: ${values.join(", ")}`;
return description ? `${description} ${choices}` : choices;
}

export function buildFlagSpecs(inputSchema: z.ZodType): FlagSpecsResult {
const jsonSchema = z.toJSONSchema(inputSchema, {
io: "input",
Expand All @@ -43,7 +57,7 @@ export function buildFlagSpecs(inputSchema: z.ZodType): FlagSpecsResult {
flags.push({
field,
flag: flagUsage(field, kind.kind),
description: fieldSchema.description ?? "",
description: describeFlag(fieldSchema),
required: result.shape.required.has(field),
kind: kind.kind,
});
Expand Down
27 changes: 8 additions & 19 deletions src/domains/publicApi/skippedContracts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Which published contracts get no generated command, and why.

// Served by hand-written commands instead; the generator must not mint
// duplicates (flow.list is served by `qawolf flows list --remote`).
// Every name below is served by a hand-written command instead, and is listed
// so the generator does not mint a duplicate (flow.list is served by
// `qawolf flows list --remote`). That is the only reason to skip a contract: one
// whose input has no flag shape earns a place here once a hand-written command
// serves it, and until then stays out and lets the generator report what it
// cannot express.
//
// The whole `runner.*` family is hand-written as `qawolf runner`. Three of its
// inputs carry a file list or an action union and have no flag shape at all, and
Expand All @@ -12,7 +14,8 @@
// command sets sharing a prefix. The group is claimed as a whole here, so the
// contracts whose commands land in a later change are absent rather than
// generated in a shape the rest of the group does not match.
const handWrittenContractNames: ReadonlySet<string> = new Set([
/** Every contract the generator passes over. */
export const skippedContractNames: ReadonlySet<string> = new Set([
"flow.list",
"runner.evaluateSnippet",
"runner.launch",
Expand All @@ -22,17 +25,3 @@ const handWrittenContractNames: ReadonlySet<string> = new Set([
"runner.stop",
"runner.takeScreenshot",
]);

// No flag shape and no command anywhere, kept apart from the set above so
// neither list claims what is only true of the other: these are absent from the
// CLI rather than served elsewhere. `issue.update` is a discriminator-less union,
// so no set of flags can say which arm a caller means.
const unexpressibleContractNames: ReadonlySet<string> = new Set([
"issue.update",
]);

/** Every contract the generator passes over, for whichever of the two reasons. */
export const skippedContractNames: ReadonlySet<string> = new Set([
...handWrittenContractNames,
...unexpressibleContractNames,
]);
Loading