Skip to content
Open
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
24 changes: 24 additions & 0 deletions .agents/friction-log/20260805150318-focused-cli-tests/friction.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions src/cli/commands/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Logged>([
'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)
Expand Down Expand Up @@ -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(
Expand Down
26 changes: 25 additions & 1 deletion src/cli/commands/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions src/cli/internal/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IssueForm.Form | undefined> {
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.
*
Expand Down