diff --git a/.alpha-loop/learnings/issue-410-20260815-140639.md b/.alpha-loop/learnings/issue-410-20260815-140639.md new file mode 100644 index 0000000..7b51498 --- /dev/null +++ b/.alpha-loop/learnings/issue-410-20260815-140639.md @@ -0,0 +1,28 @@ +--- +issue: 410 +status: success +test_fix_retries: 0 +duration: 884 +date: 2026-08-15 +retries: 0 +traces: + plan: .alpha-loop/sessions/session/20260815-135132/traces/prompts/plan-issue-410.md + implement: .alpha-loop/sessions/session/20260815-135132/logs/issue-410-implement.log + review: .alpha-loop/sessions/session/20260815-135132/traces/outputs/review-issue-410.log + diff: .alpha-loop/sessions/session/20260815-135132/diffs/issue-410.diff +--- + +## What Worked +- Centralized Conventional Commit title and type derivation in `src/lib/conventional-commits.ts`. + +## What Failed +- Nothing. + +## Patterns +- Preserve valid title prefixes, then infer from labels, with `chore` as the conservative fallback. + +## Anti-Patterns +- Hardcoding `feat:` independently at commit and PR creation call sites. + +## Suggested Skill Updates +- None. diff --git a/.alpha-loop/learnings/session-session-20260815-135132.json b/.alpha-loop/learnings/session-session-20260815-135132.json new file mode 100644 index 0000000..af8c234 --- /dev/null +++ b/.alpha-loop/learnings/session-session-20260815-135132.json @@ -0,0 +1,89 @@ +{ + "name": "session/20260815-135132", + "branch": "session/20260815-135132", + "completed": "2026-08-15T21:12:00.428Z", + "results": [ + { + "issueNum": 410, + "title": "fix: loop hardcodes feat: on every PR and commit — double prefix, and bug fixes bump the minor version", + "status": "success", + "prUrl": "https://github.com/bradtaylorsf/alpha-loop/pull/422", + "testsPassing": true, + "verifyPassing": true, + "duration": 884, + "filesChanged": 4 + } + ], + "stages": [ + { + "stage": "plan", + "model": "", + "endpoint": "default", + "tokens_in": 63857, + "tokens_out": 49121, + "cost_usd": 0, + "wall_time_s": 155.34, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T20:51:50.076Z", + "issue_num": 410 + }, + { + "stage": "implement", + "model": "", + "endpoint": "default", + "tokens_in": 511645, + "tokens_out": 393573, + "cost_usd": 0, + "wall_time_s": 513.035, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T20:54:25.900Z", + "issue_num": 410 + }, + { + "stage": "review", + "model": "", + "endpoint": "default", + "tokens_in": 115096, + "tokens_out": 88535, + "cost_usd": 0, + "wall_time_s": 201.238, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T21:03:03.390Z", + "issue_num": 410 + }, + { + "stage": "learn", + "model": "", + "endpoint": "default", + "tokens_in": 6001, + "tokens_out": 4616, + "cost_usd": 0, + "wall_time_s": 11.116, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T21:06:39.985Z", + "issue_num": 410 + }, + { + "stage": "assumptions", + "model": "", + "endpoint": "default", + "tokens_in": 20514, + "tokens_out": 15780, + "cost_usd": 0, + "wall_time_s": 58.788, + "tool_calls": 0, + "tool_errors": 0, + "stage_success": true, + "started_at": "2026-08-15T21:06:40.008Z", + "issue_num": 410 + } + ] +} diff --git a/.alpha-loop/learnings/session-summary-session-20260815-135132.md b/.alpha-loop/learnings/session-summary-session-20260815-135132.md new file mode 100644 index 0000000..9bfae4c --- /dev/null +++ b/.alpha-loop/learnings/session-summary-session-20260815-135132.md @@ -0,0 +1,16 @@ +# Session Summary: session/20260815-135132 + +## Overview +- Issue #410 completed successfully in 15 minutes with no retries or test fixes. The implementation centralized Conventional Commit title and type derivation, removing duplicated behavior from commit and PR creation paths. + +## Recurring Patterns +- No cross-issue recurring patterns could be established from a single issue. + +## Recurring Anti-Patterns +- No recurring failures occurred. + +## Recommendations +- Route all commit and PR title generation through `src/lib/conventional-commits.ts`. + +## Metrics +| Metric | Value | diff --git a/src/commands/resume.ts b/src/commands/resume.ts index ece10ae..10fa8c8 100644 --- a/src/commands/resume.ts +++ b/src/commands/resume.ts @@ -58,6 +58,7 @@ import { type FeedbackClassification, } from '../lib/session-state.js'; import { emitLifecycleEvent } from '../lib/events.js'; +import { conventionalTitle } from '../lib/conventional-commits.js'; export type ResumeOptions = { issue?: string; @@ -309,18 +310,24 @@ function prExists(repo: string, branch: string): boolean { } /** - * Fetch the issue title from GitHub. + * Fetch the issue title and labels from GitHub for release-safe PR naming. */ -function getIssueTitle(repo: string, issueNum: number): string { +function getIssueMetadata(repo: string, issueNum: number): { title: string; labels: string[] } { const result = ghExec( - `gh issue view ${issueNum} --repo ${shellQuote(repo)} --json title`, + `gh issue view ${issueNum} --repo ${shellQuote(repo)} --json title,labels`, ); - if (result.exitCode !== 0) return `Issue #${issueNum}`; + if (result.exitCode !== 0) return { title: `Issue #${issueNum}`, labels: [] }; try { - const data = JSON.parse(result.stdout) as { title: string }; - return data.title; + const data = JSON.parse(result.stdout) as { + title: string; + labels?: Array; + }; + const labels = (data.labels ?? []) + .map((label) => typeof label === 'string' ? label : label.name) + .filter((label): label is string => typeof label === 'string'); + return { title: data.title, labels }; } catch { - return `Issue #${issueNum}`; + return { title: `Issue #${issueNum}`, labels: [] }; } } @@ -372,7 +379,8 @@ async function resumeBranch( const repo = config.repo; const baseBranch = config.baseBranch; - const title = getIssueTitle(repo, issueNum); + const issue = getIssueMetadata(repo, issueNum); + const { title } = issue; log.step(`Resuming issue #${issueNum}: ${title}`); const branchWorktree = checkoutBranchForResume(branch); @@ -460,7 +468,7 @@ This PR was recovered by \`alpha-loop resume\`. Resume creates the PR and runs b repo, base: baseBranch, head: branch, - title: `feat: ${title} (closes #${issueNum})`, + title: conventionalTitle(issue, `(closes #${issueNum})`), body: prBody, cwd: branchWorktree, }); @@ -1051,6 +1059,7 @@ export async function resumePausedIssueFromManifest( config, session, { + issueLabels: issue.labels, resumeContext: context.contextText, resumeStage: context.resumeStage, existingPrUrl: context.existingPrUrl, diff --git a/src/commands/run.ts b/src/commands/run.ts index 40b971b..e3e8772 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1569,7 +1569,7 @@ async function executeSessionRun( issuesToProcess = []; } } - const quickProcessed: Array<{ number: number; title: string }> = []; + const quickProcessed: Array<{ number: number; title: string; labels?: string[] }> = []; for (const issue of issuesToProcess) { // Check duration limit before each issue @@ -1602,6 +1602,7 @@ async function executeSessionRun( session, { signal: runAbortController.signal, + issueLabels: issue.labels, ...(epicPromptContext ? { epicContext: epicPromptContext } : {}), ...(quickWorktree ? { quickWorktree } : {}), }, @@ -1609,7 +1610,7 @@ async function executeSessionRun( if (runAbortController.signal.aborted) break; session.results.push(result); if (quickWorktree && result.status === 'success' && !isRecoveredRunResult(result)) { - quickProcessed.push({ number: issue.number, title: issue.title }); + quickProcessed.push({ number: issue.number, title: issue.title, labels: issue.labels }); } // Flip the epic checklist box when this sub-issue succeeded. diff --git a/src/lib/conventional-commits.ts b/src/lib/conventional-commits.ts new file mode 100644 index 0000000..eb60246 --- /dev/null +++ b/src/lib/conventional-commits.ts @@ -0,0 +1,74 @@ +export const CONVENTIONAL_COMMIT_TYPES = [ + 'fix', + 'feat', + 'chore', + 'docs', + 'refactor', + 'test', + 'perf', + 'build', + 'ci', +] as const; + +export type ConventionalCommitType = typeof CONVENTIONAL_COMMIT_TYPES[number]; + +export type ConventionalIssue = { + title: string; + labels?: readonly string[]; +}; + +const PREFIX_PATTERN = CONVENTIONAL_COMMIT_TYPES.join('|'); +const CONVENTIONAL_PREFIX = new RegExp(`^(${PREFIX_PATTERN}):\\s*`); +const DOUBLE_PREFIX = new RegExp(`^(${PREFIX_PATTERN}):\\s*(${PREFIX_PATTERN}):`); + +const LABEL_TYPES: ReadonlyArray = [ + ['bug', 'fix'], + ['enhancement', 'feat'], + ['feature', 'feat'], + ['documentation', 'docs'], +]; + +/** Reject titles that would make release classification depend on the outer prefix. */ +export function assertNoDoubleConventionalPrefix(title: string): void { + if (DOUBLE_PREFIX.test(title.trim())) { + throw new Error(`Generated title contains a double Conventional Commit prefix: ${title}`); + } +} + +/** Resolve an issue's Conventional Commit type without assuming every change is a feature. */ +export function conventionalType(issue: ConventionalIssue): ConventionalCommitType { + const titleType = issue.title.trim().match(CONVENTIONAL_PREFIX)?.[1] as ConventionalCommitType | undefined; + if (titleType) return titleType; + + const labels = new Set((issue.labels ?? []).map((label) => label.trim().toLowerCase())); + return LABEL_TYPES.find(([label]) => labels.has(label))?.[1] ?? 'chore'; +} + +/** Preserve an existing supported prefix or add the type derived from issue labels. */ +export function conventionalTitle(issue: ConventionalIssue, suffix?: string): string { + const issueTitle = issue.title.trim(); + const title = CONVENTIONAL_PREFIX.test(issueTitle) + ? issueTitle + : `${conventionalType(issue)}: ${issueTitle}`; + const withSuffix = suffix?.trim() ? `${title} ${suffix.trim()}` : title; + assertNoDoubleConventionalPrefix(withSuffix); + return withSuffix; +} + +/** Aggregate titles use release-safe precedence: feat, then fix, then chore. */ +export function conventionalBatchType(issues: readonly ConventionalIssue[]): 'feat' | 'fix' | 'chore' { + const types = issues.map(conventionalType); + if (types.includes('feat')) return 'feat'; + if (types.includes('fix')) return 'fix'; + return 'chore'; +} + +/** Build a guarded aggregate title for batch and quick-session artifacts. */ +export function conventionalBatchTitle( + issues: readonly ConventionalIssue[], + description: string, +): string { + const title = `${conventionalBatchType(issues)}: ${description.trim()}`; + assertNoDoubleConventionalPrefix(title); + return title; +} diff --git a/src/lib/pipeline.ts b/src/lib/pipeline.ts index b5a6e9b..747b0cc 100644 --- a/src/lib/pipeline.ts +++ b/src/lib/pipeline.ts @@ -115,6 +115,7 @@ import { type WebAppPreviewResolution, type WebAppVerificationSummary, } from './web-app-profile.js'; +import { conventionalBatchTitle, conventionalTitle } from './conventional-commits.js'; async function refreshSessionWorktree( session: SessionContext, @@ -493,6 +494,8 @@ export type PipelineResumeStage = 'implementation' | 'verification' | 'clarifica export type PipelineOptions = { epicContext?: EpicPromptContext; + /** Labels from the source issue, used to derive release-safe commit and PR titles. */ + issueLabels?: string[]; /** Abort the active agent stage when the owning run receives a signal. */ signal?: AbortSignal; resumeContext?: string; @@ -1719,6 +1722,7 @@ export async function processIssue( issueNum, title, body, + labels: pipelineOptions.issueLabels, comments: issueComments.length > 0 ? issueComments : undefined, planContent: plan.implementation || undefined, ...epicOption, @@ -1758,7 +1762,10 @@ export async function processIssue( if (implResult.exitCode === 0) { autoCommittedPaths = autoCommitDirtyWorktree( worktreePath, - `feat: implement issue #${issueNum} - ${title}`, + conventionalTitle( + { title, labels: pipelineOptions.issueLabels }, + `(closes #${issueNum})`, + ), ); } else if (!isTransientError(implResult.output)) { // Permanent failures keep a wip commit so the worktree is preserved for @@ -2600,7 +2607,10 @@ export async function processIssue( repo: config.repo, base: prBase, head: worktreeBranch, - title: `feat: ${title} (closes #${issueNum})`, + title: conventionalTitle( + { title, labels: pipelineOptions.issueLabels }, + `(closes #${issueNum})`, + ), body: prBody, cwd: worktreePath, }); @@ -2916,7 +2926,7 @@ export async function processIssue( * After the batch completes, each issue is individually updated with labels, comments, and PR. */ export async function processBatch( - issues: Array<{ number: number; title: string; body: string }>, + issues: Array<{ number: number; title: string; body: string; labels?: string[] }>, config: Config, session: SessionContext, options: PipelineOptions = {}, @@ -3055,6 +3065,7 @@ export async function processBatch( issueNum: i.number, title: i.title, body: i.body, + labels: i.labels, comments: issueComments.get(i.number)?.map((c) => ({ author: c.author, body: c.body, @@ -3218,7 +3229,7 @@ Do NOT redo work that is already committed. Build on top of existing progress.\n const issueRefs = issues.map((i) => `#${i.number}`).join(', '); autoCommittedPaths = autoCommitDirtyWorktree( worktreePath, - `feat: batch implement issues ${issueRefs}`, + conventionalBatchTitle(issues, `batch implement issues ${issueRefs}`), ); stepsCompleted.push('implement'); @@ -3610,7 +3621,7 @@ Do NOT redo work that is already committed. Build on top of existing progress.\n repo: config.repo, base: prBase, head: worktreeBranch, - title: `feat: batch implementation (${issueRefs})`, + title: conventionalBatchTitle(issues, `batch implementation (${issueRefs})`), body: prBody, cwd: worktreePath, }); @@ -3806,7 +3817,7 @@ Do NOT redo work that is already committed. Build on top of existing progress.\n export type QuickFinalizeOptions = { /** Issues that were implemented on the shared worktree, in processing order. */ - issues: Array<{ number: number; title: string }>; + issues: Array<{ number: number; title: string; labels?: string[] }>; config: Config; session: SessionContext; worktreePath: string; @@ -3964,8 +3975,8 @@ export async function finalizeQuickRun(options: QuickFinalizeOptions): Promise `#${issue.number}`).join(', '); const prTitle = epicNum !== undefined - ? `feat: quick session for epic #${epicNum} (${issueNums})` - : `feat: quick session (${issueNums})`; + ? conventionalBatchTitle(issues, `quick session for epic #${epicNum} (${issueNums})`) + : conventionalBatchTitle(issues, `quick session (${issueNums})`); let prUrl: string | undefined; diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 4d6b15f..eb20767 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -4,11 +4,14 @@ */ import type { RoadmapEpicContext } from './github.js'; +import { conventionalTitle } from './conventional-commits.js'; +import { shellQuote } from './shell.js'; export type ImplementPromptOptions = { issueNum: number; title: string; body: string; + labels?: string[]; comments?: Array<{ author: string; body: string; createdAt: string }>; planContent?: string; epicContext?: EpicPromptContext; @@ -46,6 +49,7 @@ export type BatchIssue = { issueNum: number; title: string; body: string; + labels?: string[]; comments?: Array<{ author: string; body: string; createdAt: string }>; }; @@ -209,7 +213,9 @@ export function buildIssuePlanPrompt(options: IssuePlanPromptOptions): string { * Build the implementation prompt for an AI agent. */ export function buildImplementPrompt(options: ImplementPromptOptions): string { - const { issueNum, title, body, comments, planContent, epicContext, visionContext, projectContext, previousResult, learningContext, resumeContext } = options; + const { issueNum, title, body, labels, comments, planContent, epicContext, visionContext, projectContext, previousResult, learningContext, resumeContext } = options; + const commitTitle = conventionalTitle({ title, labels }, `(closes #${issueNum})`); + const commitCommand = `git commit -m ${shellQuote(commitTitle)}`; const sections: string[] = [ `Implement GitHub issue #${issueNum}: ${title}`, @@ -283,7 +289,7 @@ export function buildImplementPrompt(options: ImplementPromptOptions): string { '## After Implementing', '1. Write tests for your changes', '2. Run the test command to verify', - `3. Commit with: git commit -m "feat: ${title} (closes #${issueNum})"`, + `3. Commit with: ${commitCommand}`, ); return sections.join('\n'); @@ -373,7 +379,9 @@ export function buildBatchImplementPrompt(options: BatchImplementPromptOptions): return entry; }).join('\n\n'); - const closesRefs = issues.map((i) => `closes #${i.issueNum}`).join(', '); + const commitCommands = issues.map((issue) => + `- Issue #${issue.issueNum}: \`git commit -m ${shellQuote(conventionalTitle(issue, `(closes #${issue.issueNum})`))}\``, + ); const sections: string[] = [ `Implement the following ${issues.length} GitHub issues. Work through them in order, committing after each one.`, @@ -419,12 +427,12 @@ export function buildBatchImplementPrompt(options: BatchImplementPromptOptions): ' a. Implement the changes', ' b. Write tests for those changes', ' c. Run the test command to verify', - ' d. Commit with: `git commit -m "feat: (closes #<issueNum>)"`', + ' d. Commit with the exact issue-specific title listed below', '4. After all issues are done, run the full test suite one final time', '', '## Commit Convention', 'Make ONE commit per issue. Each commit message must include `closes #<issueNum>` for the specific issue.', - `Example: git commit -m "feat: add user auth (closes #42)"`, + ...commitCommands, ); return sections.join('\n'); diff --git a/tests/commands/resume.test.ts b/tests/commands/resume.test.ts index a09fcc3..884d1e9 100644 --- a/tests/commands/resume.test.ts +++ b/tests/commands/resume.test.ts @@ -374,6 +374,7 @@ describe('resumeCommand', () => { logsDir: expect.stringContaining('.alpha-loop/sessions/session/20260530-120000/logs'), }), expect.objectContaining({ + issueLabels: ['needs-human-input'], resumeStage: 'clarification', existingPrUrl: null, savedWorktree: { @@ -581,7 +582,7 @@ describe('resumeCommand', () => { }); mockGhExec.mockImplementation((cmd: string) => { if (cmd === `gh pr list --repo 'owner/repo' --head 'agent/issue-269' --state open --json number --limit 1`) return ok('[]'); - if (cmd === `gh issue view 269 --repo 'owner/repo' --json title`) return ok('{"title":"Recover artifact exports"}'); + if (cmd === `gh issue view 269 --repo 'owner/repo' --json title,labels`) return ok('{"title":"Recover artifact exports","labels":[]}'); if (cmd === `gh pr list --repo 'owner/repo' --head 'session/epic-226' --state open --json number,url --limit 1`) { return ok('[{"number":999,"url":"https://github.com/owner/repo/pull/999"}]'); } @@ -641,7 +642,7 @@ describe('resumeCommand', () => { }); mockGhExec.mockImplementation((cmd: string) => { if (cmd === `gh pr list --repo 'owner/repo' --head 'agent/issue-269' --state open --json number --limit 1`) return ok('[]'); - if (cmd === `gh issue view 269 --repo 'owner/repo' --json title`) return ok('{"title":"Recover artifact exports"}'); + if (cmd === `gh issue view 269 --repo 'owner/repo' --json title,labels`) return ok('{"title":"Recover artifact exports","labels":[{"name":"bug"}]}'); if (cmd === `gh pr list --repo 'owner/repo' --head 'session/epic-123' --state open --json number,url --limit 1`) { return ok('[{"number":999,"url":"https://github.com/owner/repo/pull/999"}]'); } @@ -673,7 +674,7 @@ describe('resumeCommand', () => { repo: 'owner/repo', base: 'master', head: 'agent/issue-269', - title: 'feat: Recover artifact exports (closes #269)', + title: 'fix: Recover artifact exports (closes #269)', body: expect.stringContaining('Treat this PR as WIP until those checks pass.'), cwd: tempDir!, })); diff --git a/tests/commands/run.test.ts b/tests/commands/run.test.ts index f422e92..7648769 100644 --- a/tests/commands/run.test.ts +++ b/tests/commands/run.test.ts @@ -893,7 +893,7 @@ describe('runCommand', () => { 'Body', expect.any(Object), expect.any(Object), - expect.any(Object), + expect.objectContaining({ issueLabels: expect.any(Array) }), ); }); @@ -1397,7 +1397,7 @@ describe('runCommand', () => { 'Target body', expect.objectContaining({ dryRun: true }), expect.any(Object), - expect.any(Object), + expect.objectContaining({ issueLabels: ['ready'] }), ); expect(mockCreateSession).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ epicNum: undefined, @@ -1937,8 +1937,8 @@ Coordinate hosted work. expect(mockFinalizeQuickRun).toHaveBeenCalledTimes(1); expect(mockFinalizeQuickRun).toHaveBeenCalledWith(expect.objectContaining({ issues: [ - { number: 42, title: 'First issue' }, - { number: 43, title: 'Second issue' }, + { number: 42, title: 'First issue', labels: ['ready'] }, + { number: 43, title: 'Second issue', labels: ['ready'] }, ], worktreePath: '/tmp/quick-shared', worktreeBranch: 'agent/issue-42', diff --git a/tests/lib/conventional-commits.test.ts b/tests/lib/conventional-commits.test.ts new file mode 100644 index 0000000..7817e3a --- /dev/null +++ b/tests/lib/conventional-commits.test.ts @@ -0,0 +1,78 @@ +import { + assertNoDoubleConventionalPrefix, + conventionalBatchTitle, + conventionalBatchType, + conventionalTitle, +} from '../../src/lib/conventional-commits'; + +describe('conventional commits', () => { + test.each([ + 'fix', + 'feat', + 'chore', + 'docs', + 'refactor', + 'test', + 'perf', + 'build', + 'ci', + ])('preserves an existing %s prefix without adding another prefix', (type) => { + expect(conventionalTitle({ title: `${type}: keep this title`, labels: ['bug'] })) + .toBe(`${type}: keep this title`); + }); + + test.each([ + [['bug'], 'fix: Correct the failure'], + [['enhancement'], 'feat: Add the capability'], + [['feature'], 'feat: Add the capability'], + [['documentation'], 'docs: Explain the workflow'], + ])('maps labels %p when the issue title has no prefix', (labels, expected) => { + const title = expected.replace(/^[^:]+:\s*/, ''); + expect(conventionalTitle({ title, labels })).toBe(expected); + }); + + test('falls back to chore when neither title nor labels provide a type', () => { + expect(conventionalTitle({ title: 'Maintain the workflow', labels: ['ready'] })) + .toBe('chore: Maintain the workflow'); + }); + + test('selects batch type using feat then fix then chore precedence', () => { + expect(conventionalBatchType([ + { title: 'docs: Explain it', labels: [] }, + { title: 'Correct it', labels: ['bug'] }, + ])).toBe('fix'); + expect(conventionalBatchType([ + { title: 'Correct it', labels: ['bug'] }, + { title: 'Add it', labels: ['feature'] }, + ])).toBe('feat'); + expect(conventionalBatchType([ + { title: 'docs: Explain it', labels: [] }, + { title: 'Maintain it', labels: [] }, + ])).toBe('chore'); + }); + + test('keeps a bug-only session compatible with the release script patch rule', () => { + const title = conventionalBatchTitle([ + { title: 'Correct one', labels: ['bug'] }, + { title: 'fix: Correct two', labels: ['bug'] }, + ], 'quick session (#10, #11)'); + + const bump = /BREAKING CHANGE|^[a-z]+(\([^)]+\))?!:/m.test(title) + ? 'major' + : (/^feat(\([^)]+\))?:/m.test(title) ? 'minor' : 'patch'); + + expect(title).toBe('fix: quick session (#10, #11)'); + expect(bump).toBe('patch'); + }); + + test('rejects a generated title with two supported prefixes', () => { + expect(() => assertNoDoubleConventionalPrefix('feat: fix: whatever')) + .toThrow('double Conventional Commit prefix'); + expect(() => conventionalTitle({ title: 'feat: fix: whatever', labels: [] })) + .toThrow('double Conventional Commit prefix'); + expect(() => conventionalBatchTitle( + [{ title: 'Add it', labels: ['feature'] }], + 'fix: whatever', + )).toThrow('double Conventional Commit prefix'); + }); +}); diff --git a/tests/lib/pipeline.test.ts b/tests/lib/pipeline.test.ts index b6ce07d..88a5ff7 100644 --- a/tests/lib/pipeline.test.ts +++ b/tests/lib/pipeline.test.ts @@ -458,7 +458,7 @@ describe('processIssue', () => { ); expect(mockExec).toHaveBeenCalledWith("git add -A -- ':(exclude)alpha-loop-pause-request.json' ':(exclude,glob)**/alpha-loop-pause-request.json' .", { cwd: '/tmp/worktree' }); expect(mockExec).toHaveBeenCalledWith( - "git commit -m 'feat: implement issue #42 - Test issue'", + "git commit -m 'chore: Test issue (closes #42)'", { cwd: '/tmp/worktree' }, ); expect(mockSaveResult).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ @@ -872,7 +872,7 @@ describe('processIssue', () => { await processIssue(42, "Danger $(touch /tmp/pwned) 'quote'", 'Issue body', makeConfig(), makeSession()); expect(mockExec).toHaveBeenCalledWith( - "git commit -m 'feat: implement issue #42 - Danger $(touch /tmp/pwned) '\\''quote'\\'''", + "git commit -m 'chore: Danger $(touch /tmp/pwned) '\\''quote'\\'' (closes #42)'", { cwd: '/tmp/worktree' }, ); }); @@ -1522,6 +1522,35 @@ describe('processIssue', () => { })); }); + test('uses one preserved fix prefix for prompts, fallback commits, and the PR', async () => { + mockExec.mockImplementation((cmd: string) => { + if (cmd === 'git status --porcelain') { + return { stdout: ' M src/lib/pipeline.ts\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + await processIssue( + 410, + 'fix: Correct release classification', + 'Body', + makeConfig(), + makeSession(), + { issueLabels: ['bug'] }, + ); + + expect(mockBuildImplementPrompt).toHaveBeenCalledWith(expect.objectContaining({ + labels: ['bug'], + })); + expect(mockExec).toHaveBeenCalledWith( + "git commit -m 'fix: Correct release classification (closes #410)'", + { cwd: '/tmp/worktree' }, + ); + expect(mockCreatePR).toHaveBeenCalledWith(expect.objectContaining({ + title: 'fix: Correct release classification (closes #410)', + })); + }); + test('review gate loops back to implementer when review fails', async () => { const { existsSync, readFileSync } = require('node:fs'); const mockExistsSync = existsSync as jest.MockedFunction<typeof import('node:fs').existsSync>; @@ -1895,6 +1924,25 @@ describe('processBatch', () => { })); }); + test('derives batch prompt metadata and PR type from member issues', async () => { + const issues = [ + { number: 10, title: 'Correct it', body: 'Body', labels: ['bug'] }, + { number: 11, title: 'docs: Explain it', body: 'Body', labels: ['documentation'] }, + ]; + + await processBatch(issues, makeConfig({ batch: true }), makeSession()); + + expect(mockBuildBatchImplementPrompt).toHaveBeenCalledWith(expect.objectContaining({ + issues: expect.arrayContaining([ + expect.objectContaining({ issueNum: 10, labels: ['bug'] }), + expect.objectContaining({ issueNum: 11, labels: ['documentation'] }), + ]), + })); + expect(mockCreatePR).toHaveBeenCalledWith(expect.objectContaining({ + title: 'fix: batch implementation (#10, #11)', + })); + }); + test('dry run mode does not save batch results or traces', async () => { await processBatch(batchIssues, makeConfig({ batch: true, dryRun: true }), makeSession()); @@ -2132,7 +2180,7 @@ describe('finalizeQuickRun', () => { expect(mockCreatePR).toHaveBeenCalledWith(expect.objectContaining({ base: 'session/20260330-143000', head: 'agent/issue-42', - title: 'feat: quick session for epic #100 (#42, #43)', + title: 'chore: quick session for epic #100 (#42, #43)', })); const prBody = mockCreatePR.mock.calls[0][0].body; expect(prBody).toContain('- #42: First issue (closes #42)'); @@ -2157,11 +2205,28 @@ describe('finalizeQuickRun', () => { expect(result.merged).toBe(false); expect(mockCreatePR).toHaveBeenCalledWith(expect.objectContaining({ base: 'master', - title: 'feat: quick session (#42, #43)', + title: 'chore: quick session (#42, #43)', })); expect(mockMergePR).not.toHaveBeenCalled(); }); + test('uses fix for a bug-only quick session so the release remains a patch', async () => { + await finalizeQuickRun({ + issues: [ + { number: 42, title: 'Correct one', labels: ['bug'] }, + { number: 43, title: 'fix: Correct two', labels: ['bug'] }, + ], + config: makeConfig({ autoMerge: false }), + session: makeSession(), + worktreePath: '/tmp/quick-worktree', + worktreeBranch: 'agent/issue-42', + }); + + const title = mockCreatePR.mock.calls[0][0].title; + expect(title).toBe('fix: quick session (#42, #43)'); + expect(/^feat(\([^)]+\))?:/m.test(title)).toBe(false); + }); + test('invokes the fix agent when tests fail, then retries', async () => { mockRunTests .mockReturnValueOnce({ passed: false, output: '1 test failed' }) @@ -2283,7 +2348,7 @@ describe('auto-commit before pause (sandboxed agents that cannot commit)', () => { cwd: '/tmp/worktree' }, ); expect(mockExec).toHaveBeenCalledWith( - "git commit -m 'feat: implement issue #42 - Test issue'", + "git commit -m 'chore: Test issue (closes #42)'", { cwd: '/tmp/worktree' }, ); }); @@ -2301,7 +2366,7 @@ describe('auto-commit before pause (sandboxed agents that cannot commit)', () => // Only the control-plane file was dirty — nothing to commit. expect(result.autoCommittedByPipeline).toBeUndefined(); expect(mockExec).not.toHaveBeenCalledWith( - expect.stringContaining('git commit -m \'feat: implement issue #42'), + expect.stringContaining("git commit -m 'chore: Test issue (closes #42)'"), expect.anything(), ); }); diff --git a/tests/lib/prompts.test.ts b/tests/lib/prompts.test.ts index b6da0db..a4236aa 100644 --- a/tests/lib/prompts.test.ts +++ b/tests/lib/prompts.test.ts @@ -117,7 +117,33 @@ describe('buildImplementPrompt', () => { expect(prompt).toContain('## Before You Start'); expect(prompt).toContain('## After Implementing'); - expect(prompt).toContain('git commit -m "feat: Add login page (closes #42)"'); + expect(prompt).toContain("git commit -m 'chore: Add login page (closes #42)'"); + }); + + test('preserves a prefixed bug title in commit guidance', () => { + const prompt = buildImplementPrompt({ + issueNum: 410, + title: 'fix: Correct release classification', + body: 'Use the issue type.', + labels: ['bug'], + }); + + expect(prompt).toContain("git commit -m 'fix: Correct release classification (closes #410)'"); + expect(prompt).not.toContain('feat: fix:'); + }); + + test('shell-quotes untrusted issue titles in commit guidance', () => { + const prompt = buildImplementPrompt({ + issueNum: 410, + title: "fix: Keep $(touch /tmp/pwned), `id`, and user's data literal", + body: 'Keep shell metacharacters inert.', + labels: ['bug'], + }); + + expect(prompt).toContain( + "git commit -m 'fix: Keep $(touch /tmp/pwned), `id`, and user'\\''s data literal (closes #410)'", + ); + expect(prompt).not.toContain('git commit -m "fix: Keep $('); }); test('includes parent epic context and narrow scope guidance when provided', () => { @@ -381,6 +407,37 @@ describe('buildBatchImplementPrompt', () => { expect(prompt).toContain('## Parent Epic Context'); expect(prompt).toContain('Keep this batch limited to the listed issues'); }); + + test('provides exact commit titles derived for every issue', () => { + const prompt = buildBatchImplementPrompt({ + issues: [ + { issueNum: 10, title: 'fix: Correct it', body: 'Body', labels: ['bug'] }, + { issueNum: 11, title: 'Add it', body: 'Body', labels: ['enhancement'] }, + { issueNum: 12, title: 'Maintain it', body: 'Body' }, + ], + }); + + expect(prompt).toContain("git commit -m 'fix: Correct it (closes #10)'"); + expect(prompt).toContain("git commit -m 'feat: Add it (closes #11)'"); + expect(prompt).toContain("git commit -m 'chore: Maintain it (closes #12)'"); + expect(prompt).not.toContain('git commit -m "feat: <title>'); + }); + + test('shell-quotes every issue-specific batch commit command', () => { + const prompt = buildBatchImplementPrompt({ + issues: [{ + issueNum: 10, + title: "Fix $(touch /tmp/pwned) and user's data", + body: 'Keep shell metacharacters inert.', + labels: ['bug'], + }], + }); + + expect(prompt).toContain( + "git commit -m 'fix: Fix $(touch /tmp/pwned) and user'\\''s data (closes #10)'", + ); + expect(prompt).not.toContain('git commit -m "fix: Fix $('); + }); }); describe('buildBatchReviewPrompt', () => {