diff --git a/src/automation/runnerExecution.followups.test.ts b/src/automation/runnerExecution.followups.test.ts index 6f16c19..7574108 100644 --- a/src/automation/runnerExecution.followups.test.ts +++ b/src/automation/runnerExecution.followups.test.ts @@ -32,6 +32,18 @@ describe('fileReviewerFollowups (INT-1704)', () => { expect(create.mock.calls[0][3]).toMatchObject({ priority: 3 }); }); + it('creates top-level (standalone) issues when no parent is given (INT-1968)', async () => { + const createSubIssue = vi.fn(async () => ({})); + const createTask = vi.fn(async () => ({})); + const src = { createSubIssue, createTask } as unknown as ITaskSource; + const filed = await fileReviewerFollowups(src, undefined, review(), { autoFile: true, projectId: 'proj-1' }); + expect(filed).toBe(2); + expect(createSubIssue).not.toHaveBeenCalled(); + expect(createTask).toHaveBeenCalledTimes(2); + expect(createTask.mock.calls[0][0]).toBe('[test] add edge-case coverage'); // title + expect(createTask.mock.calls[0][2]).toBe('proj-1'); // projectId + }); + it('does nothing when the reviewer did not approve', async () => { const create = vi.fn(async () => ({})); expect(await fileReviewerFollowups(mockSource(create), 'INT-1', review({ decision: 'reject' }), { autoFile: true })).toBe(0); diff --git a/src/automation/runnerExecution.ts b/src/automation/runnerExecution.ts index 54dca21..9033667 100644 --- a/src/automation/runnerExecution.ts +++ b/src/automation/runnerExecution.ts @@ -254,14 +254,16 @@ export async function isValidProjectPath(path: string): Promise { * have already created the parent issue (`parentIssueId`). */ /** - * File the reviewer's recommendedActions as follow-up sub-issues when it - * approves (INT-1611 restore / INT-1704). Gated by `autoFile` (default OFF); + * File the reviewer's recommendedActions as follow-ups when it approves + * (INT-1611 restore / INT-1704). With a `parentIssueId` they become sub-issues; + * without one (INT-1968) they are created as top-level issues so review can still + * "just file them" off a non-issue branch. Gated by `autoFile` (default OFF); * caps at 10; each create is best-effort (failures logged, never throw). * Returns the count filed. */ export async function fileReviewerFollowups( source: ITaskSource | null, - parentIssueId: string, + parentIssueId: string | null | undefined, review: ReviewResult, opts: { autoFile?: boolean; projectId?: string } = {}, ): Promise { @@ -269,16 +271,19 @@ export async function fileReviewerFollowups( const actions = (review.recommendedActions ?? []).slice(0, 10); let filed = 0; for (const a of actions) { + const title = `[${a.type}] ${a.title}`; + const body = a.location + ? `Follow-up from reviewer.\n\nLocation: ${a.location}` + : 'Follow-up recommended by the reviewer.'; try { - await source.createSubIssue( - parentIssueId, - `[${a.type}] ${a.title}`, - a.location ? `Follow-up from reviewer.\n\nLocation: ${a.location}` : 'Follow-up recommended by the reviewer.', - { priority: 3, projectId: opts.projectId }, - ); + if (parentIssueId) { + await source.createSubIssue(parentIssueId, title, body, { priority: 3, projectId: opts.projectId }); + } else { + await source.createTask(title, body, opts.projectId); + } filed += 1; } catch (err) { - console.error(`[Runner] follow-up sub-issue create failed (${a.title}):`, err); + console.error(`[Runner] follow-up issue create failed (${a.title}):`, err); } } return filed; diff --git a/src/cli/reviewCommand.test.ts b/src/cli/reviewCommand.test.ts index 197357b..514ea03 100644 --- a/src/cli/reviewCommand.test.ts +++ b/src/cli/reviewCommand.test.ts @@ -81,8 +81,8 @@ describe('runReviewCommand --issues branch inference (INT-1967)', () => { expect(getBranch).not.toHaveBeenCalled(); }); - it('guides when --issues is set but the branch has no issue id', async () => { - const fileFollowups = vi.fn(async () => 1); + it('files standalone issues when --issues is set but the branch has no issue id (INT-1968)', async () => { + const fileFollowups = vi.fn(async () => 2); const logs: string[] = []; await runReviewCommand( { fileIssue: true }, @@ -95,8 +95,8 @@ describe('runReviewCommand --issues branch inference (INT-1967)', () => { log: (l) => logs.push(l), }, ); - expect(fileFollowups).not.toHaveBeenCalled(); - expect(logs.join('\n')).toMatch(/no issue could be inferred/); + expect(fileFollowups).toHaveBeenCalledWith(undefined, expect.anything()); // no parent → standalone + expect(logs.join('\n')).toMatch(/standalone follow-up issue/); }); }); diff --git a/src/cli/reviewCommand.ts b/src/cli/reviewCommand.ts index 9755ce6..a2dfc59 100644 --- a/src/cli/reviewCommand.ts +++ b/src/cli/reviewCommand.ts @@ -76,6 +76,17 @@ export function resolveIssueFromBranch(branch: string): string | undefined { return m ? m[1].toUpperCase() : undefined; } +/** Best-effort Linear project id from /openswarm.json, so standalone follow-ups land in the right project. (INT-1968) */ +async function resolveProjectId(cwd: string): Promise { + try { + const { loadRepoMetadata } = await import('../support/repoMetadata.js'); + const meta = await loadRepoMetadata(cwd); + return meta?.linear?.projectId; + } catch { + return undefined; + } +} + export interface ReviewCommandOptions { /** Project path (default cwd). */ path?: string; @@ -98,7 +109,7 @@ export async function runReviewCommand( deps: { getChangedFiles?: (cwd: string) => Promise; review?: (wr: WorkerResult, cwd: string, onLog?: (line: string) => void) => Promise; - fileFollowups?: (parentIssueId: string, review: ReviewResult) => Promise; + fileFollowups?: (parentIssueId: string | undefined, review: ReviewResult) => Promise; log?: (line: string) => void; /** Override the progress indicator (default: TTY-gated spinner). Tests pass a stub. */ startProgress?: () => { note: (line: string) => void; stop: () => void } | null; @@ -168,21 +179,20 @@ export async function runReviewCommand( parent = resolveIssueFromBranch(branch); if (parent) log(`Filing follow-ups under ${parent} (inferred from branch "${branch}").`); } - if (!parent) { - log( - `\n${followups} follow-up(s) suggested, but no issue could be inferred from the branch. ` + - 'Re-run with `--issues ` to choose the parent.', - ); - return result; - } + // No parent → create top-level (standalone) issues rather than refusing. (INT-1968) const fileFollowups = deps.fileFollowups ?? - (async (p: string, r: ReviewResult) => { + (async (p: string | undefined, r: ReviewResult) => { const { fileReviewerFollowups, getTaskSource } = await import('../automation/runnerExecution.js'); - return fileReviewerFollowups(getTaskSource(), p, r, { autoFile: true }); + const projectId = p ? undefined : await resolveProjectId(cwd); + return fileReviewerFollowups(getTaskSource(), p, r, { autoFile: true, projectId }); }); const filed = await fileFollowups(parent, result); - log(`Filed ${filed} follow-up sub-issue(s) under ${parent}.`); + log( + parent + ? `Filed ${filed} follow-up sub-issue(s) under ${parent}.` + : `Filed ${filed} standalone follow-up issue(s) (no issue id on the branch — pass \`--issues \` to nest them).`, + ); } else if (followups) { // Suggestions were made but nothing was filed — make the flag discoverable. (INT-1966/1967) log(`\n${followups} follow-up(s) suggested. Re-run with \`--issues\` to create them as Linear sub-issues (parent inferred from the branch, or pass \`--issues \`).`);