diff --git a/.agents/friction-log/20260805150318-focused-cli-tests/friction.md b/.agents/friction-log/20260805150318-focused-cli-tests/friction.md new file mode 100644 index 0000000..7bdae23 --- /dev/null +++ b/.agents/friction-log/20260805150318-focused-cli-tests/friction.md @@ -0,0 +1,24 @@ +--- +title: 'Focused CLI tests require a container runtime' +severity: 'minor' +--- + +## Expected Behavior + +Focused CLI tests run without unrelated database infrastructure. + +## Current Behavior + +`pnpm test src/cli/commands/log.test.ts` starts Testcontainers during module setup and fails before collecting any CLI tests when no container runtime is available. + +## Possible Solution + +Initialize the Postgres helper only for durable-store tests, or isolate those cases in a database-specific suite. + +## Minimal Reproducible Example + +Run `pnpm test src/cli/commands/log.test.ts` without Docker or another compatible container runtime. + +## Context + +This blocked focused validation of a local `frog log` CLI change; typechecking and non-container checks remain available. diff --git a/README.md b/README.md index 7829cfb..4d39d63 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,13 @@ Prompts for the details in a terminal, or takes them as flags. frog log ``` +Select another local issue form when a class of friction needs different evidence. The path may be a +filename under `.github/ISSUE_TEMPLATE` or a repository-relative path: + +```sh +frog log --template flaky-test.yml --label flaky-test +``` + ``` .agents/friction-log/20260725143012-pnpm-test-files/ friction.md the write-up diff --git a/src/cli/commands/log.test.ts b/src/cli/commands/log.test.ts index 9365fce..7dcbdcd 100644 --- a/src/cli/commands/log.test.ts +++ b/src/cli/commands/log.test.ts @@ -285,6 +285,66 @@ test('behavior: this repository configured issue form wins', async () => { expect((await Store.get(id, { root: cwd })).body).toBe(configuredBody) }) +test('behavior: an explicitly selected issue form wins', async () => { + const cwd = await helpers.repo() + await writeOwnForm(cwd) + await writeOwnForm( + cwd, + [ + 'name: Flaky Test', + 'body:', + ' - type: textarea', + ' attributes:', + ' label: Passing Retry', + ' validations:', + ' required: true', + ].join('\n'), + 'flaky-test.yml', + ) + const selectedBody = '### Passing Retry\n\nThe unchanged retry passed.' + + const { id } = await cli.data([ + 'log', + title, + '--body', + selectedBody, + '--template', + 'flaky-test.yml', + '--cwd', + cwd, + ]) + + expect((await Store.get(id, { root: cwd })).body).toBe(selectedBody) +}) + +test('error: an explicitly selected issue form must resolve', async () => { + const cwd = await helpers.repo() + await writeOwnForm(cwd) + + const result = await cli.error(['log', title, '--template', 'missing.yml', '--cwd', cwd]) + + expect(result).toMatchInlineSnapshot(` + { + "code": "TEMPLATE_NOT_FOUND", + "message": "Could not load an issue form from \`missing.yml\`.", + } + `) + expect(await Store.list({ root: cwd })).toEqual([]) +}) + +test('error: selecting an issue form requires the file store', async () => { + const store = await postgres.store() + const cwd = await helpers.repo() + + expect( + await cli.error( + ['log', title, '--body', body, '--template', 'flaky-test.yml', '--cwd', cwd], + {}, + { store }, + ), + ).toMatchObject({ code: 'STORE_UNSUPPORTED_OPTION' }) +}) + test('error: a body broken in the editor must preserve this repository own issue form', async () => { const cwd = await helpers.repo() await writeOwnForm(cwd) @@ -437,6 +497,25 @@ describe('--target', () => { `) }) + test('error: selecting a local issue form cannot target another repository', async () => { + const cwd = await consumer() + + expect( + await cli.error([ + 'log', + title, + '--body', + body, + '--target', + upstream, + '--template', + 'flaky-test.yml', + '--cwd', + cwd, + ]), + ).toMatchObject({ code: 'TEMPLATE_UNSUPPORTED_TARGET' }) + }) + test('behavior: a template named in the target config wins', async () => { const cwd = await consumer() const instance = await github( diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts index 46b0050..e56ad5c 100644 --- a/src/cli/commands/log.ts +++ b/src/cli/commands/log.ts @@ -76,6 +76,11 @@ export const log = Cli.create('log', { .optional() .describe('File the issue immediately instead of leaving it for `publish`.'), severity: Entry.Severity.optional().describe('Impact. Defaults to minor.'), + template: z + .string() + .min(1) + .optional() + .describe('Issue form for this entry, as a path or filename under `.github/ISSUE_TEMPLATE`.'), token: z.string().min(1).optional().describe('GitHub token. Overrides the environment.'), target: z .string() @@ -165,12 +170,31 @@ export const log = Cli.create('log', { // An explicit target can still resolve to this repository, directly or through a package. const targetRepo = c.options.target ? await target.repository(c.options.target, root) : repo const ownTarget = !c.options.target || (repo !== undefined && targetRepo === repo) + if (c.options.template && !ownTarget) + return c.error({ + code: 'TEMPLATE_UNSUPPORTED_TARGET', + message: '`--template` is available only for entries about this repository.', + }) + if (c.options.template && store.name !== 'file') + return c.error({ + code: 'STORE_UNSUPPORTED_OPTION', + message: '`--template` is available only with the repository file store.', + }) // Always load this repository's configured form from disk so a supplied body cannot bypass it. const own = ownTarget && store.name === 'file' - ? await attempt(form.own(root, { named: config.inbound.template })) + ? await attempt( + c.options.template + ? form.selected(root, c.options.template) + : form.own(root, { named: config.inbound.template }), + ) : undefined + if (c.options.template && own?.ok && !own.value) + return c.error({ + code: 'TEMPLATE_NOT_FOUND', + message: `Could not load an issue form from \`${c.options.template}\`.`, + }) // Scaffold from the target's own issue form rather than from Frog's sections. An upstream project // judges a report against its own form. Fetched only when the answers would be used. Never fatal: diff --git a/src/cli/internal/form.ts b/src/cli/internal/form.ts index 2dc1d86..17e8c72 100644 --- a/src/cli/internal/form.ts +++ b/src/cli/internal/form.ts @@ -93,6 +93,19 @@ export declare namespace own { } } +/** + * Reads the explicitly selected issue form without falling back to another form. + * + * @param root - Repository root. + * @param value - Repository-relative path or filename under the issue-template directory. + * @returns The selected form, or `undefined` when it is absent or invalid. + */ +export async function selected(root: string, value: string): Promise { + const at = value.includes('/') ? value : `${IssueForm.dir}/${value}` + const contents = await fs.readFile(path.join(root, at), 'utf8').catch(() => undefined) + return contents ? IssueForm.parse(contents) : undefined +} + /** * Checks that a supplied body preserves an issue form's headings and required answers. *