diff --git a/.changeset/enum-flag-choices-in-help.md b/.changeset/enum-flag-choices-in-help.md new file mode 100644 index 000000000..1b19ac302 --- /dev/null +++ b/.changeset/enum-flag-choices-in-help.md @@ -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. diff --git a/.changeset/issue-update-command.md b/.changeset/issue-update-command.md new file mode 100644 index 000000000..f11031cc5 --- /dev/null +++ b/.changeset/issue-update-command.md @@ -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. diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index 68a3b4b63..0146b544d 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -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. | diff --git a/src/core/publicApi/flagSpecs.test.ts b/src/core/publicApi/flagSpecs.test.ts index 2c4f6669e..266edec0f 100644 --- a/src/core/publicApi/flagSpecs.test.ts +++ b/src/core/publicApi/flagSpecs.test.ts @@ -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() }) diff --git a/src/core/publicApi/flagSpecs.ts b/src/core/publicApi/flagSpecs.ts index 683db4c97..64d66f2b5 100644 --- a/src/core/publicApi/flagSpecs.ts +++ b/src/core/publicApi/flagSpecs.ts @@ -28,6 +28,20 @@ function flagUsage(field: string, kind: FlagKind): string { return `${name} `; } +// 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", @@ -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, }); diff --git a/src/domains/publicApi/skippedContracts.ts b/src/domains/publicApi/skippedContracts.ts index 6994ee02c..9c6c28d5e 100644 --- a/src/domains/publicApi/skippedContracts.ts +++ b/src/domains/publicApi/skippedContracts.ts @@ -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 @@ -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 = new Set([ +/** Every contract the generator passes over. */ +export const skippedContractNames: ReadonlySet = new Set([ "flow.list", "runner.evaluateSnippet", "runner.launch", @@ -22,17 +25,3 @@ const handWrittenContractNames: ReadonlySet = 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 = new Set([ - "issue.update", -]); - -/** Every contract the generator passes over, for whichever of the two reasons. */ -export const skippedContractNames: ReadonlySet = new Set([ - ...handWrittenContractNames, - ...unexpressibleContractNames, -]);