diff --git a/src/infra/prompt-templates.ts b/src/infra/prompt-templates.ts index 25e6ea2..27e8944 100644 --- a/src/infra/prompt-templates.ts +++ b/src/infra/prompt-templates.ts @@ -389,6 +389,7 @@ Requirements: 3. Summary must explain the user problem and the intended fix. 4. Changes, validation, and risks must be flat string lists. 5. Validation must mention the provided test commands and whether they passed or are still pending. +6. Follow the repository contribution rules and PR template when they are provided. Output schema: { @@ -412,6 +413,9 @@ Patch Draft: Validation Context: {{validationContext}} + +Repository Contribution Rules: +{{contributionRulesContext}} `; export const REPOSITORY_ANALYSIS_PROMPT = `You are OpenMeta, an autonomous open source contribution agent. diff --git a/src/orchestration/agent.ts b/src/orchestration/agent.ts index b62f7c9..9f57442 100644 --- a/src/orchestration/agent.ts +++ b/src/orchestration/agent.ts @@ -3063,6 +3063,35 @@ export class AgentOrchestrator { }; } + const unsatisfiedContributionRules = this.evaluateUnsatisfiedContributionRules( + input.workspace, + hasBlockingValidationFailures, + ); + if (unsatisfiedContributionRules.length > 0) { + const message = `Skipping real draft PR creation because repository contribution requirements are not satisfied: ${unsatisfiedContributionRules.join('; ')}`; + logger.warn(message); + input.workspace.validationWarnings = [...input.workspace.validationWarnings, ...unsatisfiedContributionRules]; + ui.callout({ + label: 'OpenMeta Agent', + title: 'Repository constraints require review', + subtitle: 'OpenMeta kept this run in artifact-only mode to avoid opening a process-invalid PR.', + lines: unsatisfiedContributionRules, + tone: 'warning', + }); + return { + changedFiles: input.changedFiles, + validationResults: input.validationResults, + }; + } + + if (input.headless && hasBlockingValidationFailures) { + logger.warn('Skipping real draft PR creation because validation failed in headless mode.'); + return { + changedFiles: input.changedFiles, + validationResults: input.validationResults, + }; + } + if (!input.headless) { ui.callout({ label: 'OpenMeta Agent', @@ -3102,6 +3131,7 @@ export class AgentOrchestrator { prDraft: input.prDraft, workspacePath: input.workspace.workspacePath, changedFiles: input.changedFiles, + contributionRules: input.workspace.contributionRules, }); ui.card({ @@ -3261,6 +3291,29 @@ export class AgentOrchestrator { return results.some((result) => !result.passed && !this.isInfrastructureValidationFailure(result)); } + private evaluateUnsatisfiedContributionRules( + workspace: RepoWorkspaceContext, + hasBlockingValidationFailures: boolean, + ): string[] { + const rules = workspace.contributionRules; + if (!rules) { + return []; + } + + const missing: string[] = []; + if (rules.requiresPriorDiscussion) { + missing.push( + 'Repository guidance indicates prior maintainer discussion or approval is required before opening a PR.', + ); + } + + if (rules.requiresPassingValidation && hasBlockingValidationFailures) { + missing.push('Repository guidance requires passing validation before opening a PR.'); + } + + return missing; + } + private resolveMachineExecutionOutcome(input: { draftOnly: boolean; localArtifactsOnly: boolean; diff --git a/src/services/content.ts b/src/services/content.ts index 81d74a5..2809d73 100644 --- a/src/services/content.ts +++ b/src/services/content.ts @@ -284,6 +284,20 @@ export class ContentService { `- Tech Requirements: ${issue.analysis.techRequirements.join(', ') || 'n/a'}`, `- Estimated Workload: ${issue.analysis.estimatedWorkload || 'n/a'}`, '', + '## Repository Contribution Rules', + '', + `- Detected Rule Files: ${workspace.contributionRules?.detectedFiles.join(', ') || 'none'}`, + `- PR Template: ${workspace.contributionRules?.prTemplatePath || 'not detected'}`, + `- Required Checklist Items: ${workspace.contributionRules?.requiredChecklistItems.join(' | ') || 'none'}`, + `- PR Title Rules: ${workspace.contributionRules?.prTitleRules.join(' | ') || 'none'}`, + `- Commit Message Rules: ${workspace.contributionRules?.commitMessageRules.join(' | ') || 'none'}`, + `- Branch Naming Rules: ${workspace.contributionRules?.branchNamingRules.join(' | ') || 'none'}`, + `- Validation Rules: ${workspace.contributionRules?.requiredValidationRules.join(' | ') || 'none'}`, + `- Issue Linking Rules: ${workspace.contributionRules?.issueLinkingRules.join(' | ') || 'none'}`, + `- Release Note Rules: ${workspace.contributionRules?.releaseNoteRules.join(' | ') || 'none'}`, + `- Requires Prior Discussion: ${workspace.contributionRules?.requiresPriorDiscussion ? 'yes' : 'no'}`, + `- Requires Passing Validation: ${workspace.contributionRules?.requiresPassingValidation ? 'yes' : 'no'}`, + '', '## Detected Test Commands', '', ...(workspace.testCommands.length > 0 diff --git a/src/services/contribution-pr.ts b/src/services/contribution-pr.ts index 5723c10..73a99ca 100644 --- a/src/services/contribution-pr.ts +++ b/src/services/contribution-pr.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import type { PullRequestDraft } from '../contracts/index.js'; import { logger } from '../infra/index.js'; -import type { RankedIssue } from '../types/index.js'; +import type { RankedIssue, RepositoryContributionRules } from '../types/index.js'; import { contentService } from './content.js'; import { githubService } from './github.js'; @@ -24,6 +24,7 @@ export interface ContributionPrSubmissionInput { prDraft: PullRequestDraft; workspacePath: string; changedFiles: string[]; + contributionRules?: RepositoryContributionRules; } export interface ContributionPrSubmissionResult { @@ -42,9 +43,9 @@ export class ContributionPrService { async submitDraftPullRequest(input: ContributionPrSubmissionInput): Promise { const upstreamRepo = await this.getUpstreamRepositoryContext(input.issue); const forkRepo = await this.ensureForkRepository(upstreamRepo); - const branchName = this.buildPublishBranchName(input.issue); - const draftPullRequest = this.buildDraftPullRequest(input.prDraft); - const commitMessage = this.buildContributionCommitMessage(input.issue); + const branchName = this.buildPublishBranchName(input.issue, input.contributionRules); + const draftPullRequest = this.buildDraftPullRequest(input.prDraft, input.issue, input.contributionRules); + const commitMessage = this.buildContributionCommitMessage(input.issue, input.contributionRules); await this.createCommitOnFork({ forkRepo, @@ -68,25 +69,71 @@ export class ContributionPrService { }; } - buildDraftPullRequest(prDraft: PullRequestDraft): DraftPullRequest { + buildDraftPullRequest( + prDraft: PullRequestDraft, + issue?: RankedIssue, + contributionRules?: RepositoryContributionRules, + ): DraftPullRequest { + const generatedBody = contentService.formatPullRequestDraftBody(prDraft); + const templateBody = contributionRules?.prTemplate?.trim(); + const issueLinkLine = issue ? `Closes ${issue.repoFullName}#${issue.number}` : undefined; + const releaseNoteLine = contributionRules?.requiresReleaseNotes + ? 'Release Notes: include a brief summary of the user-visible change.' + : undefined; + + const body = templateBody + ? [templateBody, '', '---', '', issueLinkLine, releaseNoteLine, generatedBody].filter(Boolean).join('\n') + : [issueLinkLine, releaseNoteLine, generatedBody].filter(Boolean).join('\n\n'); + return { title: prDraft.title, - body: contentService.formatPullRequestDraftBody(prDraft), + body, }; } - buildPublishBranchName(issue: RankedIssue): string { + buildPublishBranchName(issue: RankedIssue, contributionRules?: RepositoryContributionRules): string { + const prefix = this.resolveBranchPrefix(contributionRules); const slug = issue.title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 32); - return `openmeta/agent-${issue.number}-${slug || 'issue'}-${Date.now()}`; + return `${prefix}${issue.number}-${slug || 'issue'}-${Date.now()}`; + } + + buildContributionCommitMessage(issue: RankedIssue, contributionRules?: RepositoryContributionRules): string { + if (this.requiresConventionalCommit(contributionRules)) { + return `feat: address ${issue.repoFullName}#${issue.number} ${issue.title}`.slice(0, 120); + } + + return `address ${issue.repoFullName}#${issue.number}: ${issue.title}`.slice(0, 120); } - buildContributionCommitMessage(issue: RankedIssue): string { - return `feat: address ${issue.repoFullName}#${issue.number} ${issue.title}`.slice(0, 120); + private resolveBranchPrefix(contributionRules?: RepositoryContributionRules): string { + const rules = contributionRules?.branchNamingRules.join(' ').toLowerCase() || ''; + if (rules.includes('feature/')) { + return 'feature/'; + } + + if (rules.includes('bugfix/')) { + return 'bugfix/'; + } + + if (rules.includes('fix/')) { + return 'fix/'; + } + + return 'openmeta/agent-'; + } + + private requiresConventionalCommit(contributionRules?: RepositoryContributionRules): boolean { + const rules = contributionRules?.commitMessageRules.join(' ').toLowerCase() || ''; + if (!rules) { + return true; + } + + return /conventional|type\(scope\):|feat:|fix:|chore:|docs:/.test(rules); } private async getUpstreamRepositoryContext(issue: RankedIssue): Promise { diff --git a/src/services/llm.ts b/src/services/llm.ts index d1bde11..292bfb7 100644 --- a/src/services/llm.ts +++ b/src/services/llm.ts @@ -41,6 +41,7 @@ import type { RankedIssue, RepoFileSnippet, RepoMemory, + RepositoryContributionRules, RepoWorkspaceContext, TestResult, UserProfile, @@ -275,6 +276,7 @@ Repo Stars: ${i.repoStars}`, issueContext: this.formatRankedIssue(issue), patchDraft: JSON.stringify(patchDraft, null, 2), validationContext: contextAssemblerService.buildValidationContext(workspace), + contributionRulesContext: this.formatContributionRules(workspace.contributionRules), }); return this.generateStructuredOutput({ @@ -575,6 +577,37 @@ Repo Stars: ${i.repoStars}`, ].join('\n'); } + private formatContributionRules(rules?: RepositoryContributionRules): string { + if (!rules) { + return 'No repository-specific contribution rules were detected.'; + } + + const checklist = + rules.requiredChecklistItems.length > 0 + ? rules.requiredChecklistItems.map((item) => `- ${item}`).join('\n') + : '- none'; + const template = rules.prTemplate + ? `\nPR Template (${rules.prTemplatePath || 'detected'}):\n${rules.prTemplate}` + : ''; + + return [ + `Detected Rule Files: ${rules.detectedFiles.join(', ') || 'none'}`, + `PR Title Rules: ${rules.prTitleRules.join(' | ') || 'none'}`, + `Commit Message Rules: ${rules.commitMessageRules.join(' | ') || 'none'}`, + `Branch Naming Rules: ${rules.branchNamingRules.join(' | ') || 'none'}`, + `Validation Rules: ${rules.requiredValidationRules.join(' | ') || 'none'}`, + `Issue Linking Rules: ${rules.issueLinkingRules.join(' | ') || 'none'}`, + `Release Note Rules: ${rules.releaseNoteRules.join(' | ') || 'none'}`, + `Checklist Items:\n${checklist}`, + `Prior Discussion Required: ${rules.requiresPriorDiscussion ? 'yes' : 'no'}`, + `Issue Linking Required: ${rules.requiresIssueLinking ? 'yes' : 'no'}`, + `Release Notes Required: ${rules.requiresReleaseNotes ? 'yes' : 'no'}`, + `Passing Validation Required: ${rules.requiresPassingValidation ? 'yes' : 'no'}`, + template, + ] + .filter(Boolean) + .join('\n\n'); + } private formatEnvironment(environment: EnvironmentInfo): string { const availableTools = environment.tools .filter((tool) => tool.available) diff --git a/src/services/workspace.ts b/src/services/workspace.ts index 6818bbf..3c4c037 100644 --- a/src/services/workspace.ts +++ b/src/services/workspace.ts @@ -10,6 +10,7 @@ import type { RankedIssue, RepoFileSnippet, RepoMemory, + RepositoryContributionRules, RepoWorkspaceContext, TestCommand, TestResult, @@ -24,6 +25,7 @@ const MAX_SNIPPET_CHARS = 8000; const MAX_GENERATED_FILES = 6; const MAX_GENERATED_FILE_CHARS = 60_000; const DEFAULT_EXPANSION_LIMIT = 8; +const MAX_CONTRIBUTION_RULE_SNIPPET_CHARS = 12_000; type ExecutionMode = 'interactive' | 'headless'; function normalizeRepoRelativePath(path: string): string { @@ -503,6 +505,7 @@ export class WorkspaceService { executionMode: ExecutionMode; }): Promise { const topLevelFiles = readdirSync(input.workspacePath).slice(0, 50); + const contributionRules = this.detectContributionRules(input.workspacePath); const snippets = input.candidateFiles.map((path) => ({ path, content: this.readSnippet(join(input.workspacePath, path)), @@ -521,6 +524,7 @@ export class WorkspaceService { workspaceDirty: input.workspaceDirty, defaultBranch: input.defaultBranch, branchName: input.branchName, + contributionRules, topLevelFiles, candidateFiles: input.candidateFiles, snippets, @@ -531,6 +535,160 @@ export class WorkspaceService { }; } + private detectContributionRules(workspacePath: string): RepositoryContributionRules | undefined { + const discoveredFiles = this.discoverFiles(workspacePath); + const contributionRuleFiles = [ + ...new Set(discoveredFiles.filter((path) => this.isContributionRuleFile(path))), + ].slice(0, 24); + + if (contributionRuleFiles.length === 0) { + return undefined; + } + + const sourceSnippets: RepositoryContributionRules['sourceSnippets'] = []; + const requiredChecklistItems: string[] = []; + const prTitleRules: string[] = []; + const commitMessageRules: string[] = []; + const branchNamingRules: string[] = []; + const requiredValidationRules: string[] = []; + const issueLinkingRules: string[] = []; + const releaseNoteRules: string[] = []; + let requiresPriorDiscussion = false; + let requiresIssueLinking = false; + let requiresReleaseNotes = false; + let requiresPassingValidation = false; + let prTemplatePath: string | undefined; + let prTemplate: string | undefined; + + for (const relativePath of contributionRuleFiles) { + const fullPath = join(workspacePath, relativePath); + if (!existsSync(fullPath)) { + continue; + } + + const rawContent = readFileSync(fullPath, 'utf-8'); + const normalizedContent = rawContent.trim(); + if (!normalizedContent) { + continue; + } + + sourceSnippets.push({ + path: relativePath, + excerpt: normalizedContent.slice(0, MAX_CONTRIBUTION_RULE_SNIPPET_CHARS), + }); + + if (!prTemplatePath && this.isPullRequestTemplatePath(relativePath)) { + prTemplatePath = relativePath; + prTemplate = normalizedContent.slice(0, MAX_CONTRIBUTION_RULE_SNIPPET_CHARS); + } + + const lines = normalizedContent.split(/\r?\n/).map((line) => line.trim()); + for (const line of lines) { + if (!line) { + continue; + } + + const checklistMatch = line.match(/^- \[(?: |x|X)\]\s+(.+)$/); + if (checklistMatch?.[1]) { + requiredChecklistItems.push(checklistMatch[1].trim()); + } + + const loweredLine = line.toLowerCase(); + if (/(pr title|pull request title|title format|title should|title must)/i.test(line)) { + prTitleRules.push(line); + } + + if (/(commit message|conventional commit|squash commit|commit format|commit should|commit must)/i.test(line)) { + commitMessageRules.push(line); + } + + if (/(branch name|branch naming|name your branch|branch should|branch must|branches should)/i.test(line)) { + branchNamingRules.push(line); + } + + if (/(test|validation|ci|lint|typecheck|build)/i.test(line) && /(must|required|should)/i.test(line)) { + requiredValidationRules.push(line); + } + + if ( + /(issue|fixes|closes|resolves|linked issue|link to issue|issue link)/i.test(line) && + /(must|required|should|include|add|needs)/i.test(line) + ) { + issueLinkingRules.push(line); + } + + if ( + /(release note|release-notes|changelog|breaking change)/i.test(line) && + /(must|required|should|include|add|needs)/i.test(line) + ) { + releaseNoteRules.push(line); + } + + if ( + /(discuss|discussion|approval|maintainer approval|before opening|before submitting)/i.test(line) && + /(must|required|should|need|first)/i.test(line) + ) { + requiresPriorDiscussion = true; + } + + if (/(fixes|closes|resolves)\s+#\d+/i.test(line) || loweredLine.includes('linked issue')) { + requiresIssueLinking = true; + } + + if ( + /(release note|release-notes|changelog|breaking change)/i.test(line) && + /(must|required|need|include|add)/i.test(line) + ) { + requiresReleaseNotes = true; + } + + if (/(test|validation|ci|lint|typecheck|build)/i.test(line) && /(must|required|need|pass)/i.test(line)) { + requiresPassingValidation = true; + } + } + } + + return { + detectedFiles: contributionRuleFiles, + sourceSnippets, + prTemplatePath, + prTemplate, + requiredChecklistItems: [...new Set(requiredChecklistItems)], + prTitleRules: [...new Set(prTitleRules)], + commitMessageRules: [...new Set(commitMessageRules)], + branchNamingRules: [...new Set(branchNamingRules)], + requiredValidationRules: [...new Set(requiredValidationRules)], + issueLinkingRules: [...new Set(issueLinkingRules)], + releaseNoteRules: [...new Set(releaseNoteRules)], + requiresPriorDiscussion, + requiresIssueLinking, + requiresReleaseNotes, + requiresPassingValidation, + }; + } + + private isContributionRuleFile(path: string): boolean { + const normalized = normalizeRepoRelativePath(path).toLowerCase(); + const isDocLike = /\.(md|markdown|txt|adoc|rst)$/i.test(normalized); + return ( + normalized === 'contributing.md' || + normalized.endsWith('/contributing.md') || + normalized === 'codeowners' || + normalized.endsWith('/codeowners') || + normalized === '.github/pull_request_template.md' || + normalized.startsWith('.github/pull_request_template/') || + normalized === '.github/issue_template.md' || + normalized.startsWith('.github/issue_template/') || + (isDocLike && + (normalized.includes('release') || normalized.includes('changelog') || normalized.includes('commit'))) + ); + } + + private isPullRequestTemplatePath(path: string): boolean { + const normalized = normalizeRepoRelativePath(path).toLowerCase(); + return normalized === '.github/pull_request_template.md' || normalized.startsWith('.github/pull_request_template/'); + } + private async createWorkspaceBranchName(git: SimpleGit, issue: RankedIssue): Promise { const baseBranchName = `openmeta/${issue.number}-${slugify(issue.title) || 'issue'}`; const localBranches = await git.branchLocal(); diff --git a/src/types/agent.types.ts b/src/types/agent.types.ts index c07f448..c2498fe 100644 --- a/src/types/agent.types.ts +++ b/src/types/agent.types.ts @@ -63,6 +63,24 @@ export interface RepoFileSnippet { content: string; } +export interface RepositoryContributionRules { + detectedFiles: string[]; + sourceSnippets: Array<{ path: string; excerpt: string }>; + prTemplatePath?: string; + prTemplate?: string; + requiredChecklistItems: string[]; + prTitleRules: string[]; + commitMessageRules: string[]; + branchNamingRules: string[]; + requiredValidationRules: string[]; + issueLinkingRules: string[]; + releaseNoteRules: string[]; + requiresPriorDiscussion: boolean; + requiresIssueLinking: boolean; + requiresReleaseNotes: boolean; + requiresPassingValidation: boolean; +} + export interface GeneratedFileChange { path: string; reason: string; @@ -91,6 +109,7 @@ export interface RepoWorkspaceContext { workspaceDirty: boolean; defaultBranch: string; branchName?: string; + contributionRules?: RepositoryContributionRules; topLevelFiles: string[]; candidateFiles: string[]; snippets: RepoFileSnippet[]; diff --git a/test/content.test.ts b/test/content.test.ts index c861d1e..02c341e 100644 --- a/test/content.test.ts +++ b/test/content.test.ts @@ -37,6 +37,8 @@ describe('contentService', () => { expect(markdown).toContain('- Repo Stars: 240'); expect(markdown).toContain('- Issue Link: https://github.com/acme/demo/issues/42'); expect(markdown).toContain('- Labels: good first issue, help wanted'); + expect(markdown).toContain('## Repository Contribution Rules'); + expect(markdown).toContain('- Detected Rule Files: none'); expect(markdown).toContain('- `bun test` | Detected Bun tests | repo-script'); expect(markdown).toContain('## Runnable Validation Commands'); expect(markdown).toContain('## Validation Safety Notes'); diff --git a/test/contribution-pr.test.ts b/test/contribution-pr.test.ts index a3befb4..398b83f 100644 --- a/test/contribution-pr.test.ts +++ b/test/contribution-pr.test.ts @@ -54,6 +54,41 @@ describe('ContributionPrService', () => { expect(commitMessage.length).toBeLessThanOrEqual(120); }); + test('applies repository contribution rules to template body, branch prefix, and commit format', () => { + const issue = createRankedIssue({ + repoFullName: 'acme/widgets', + number: 42, + title: 'Fix keyboard focus in icon-only widgets with an intentionally long title', + }); + const rules = { + detectedFiles: ['CONTRIBUTING.md', '.github/PULL_REQUEST_TEMPLATE.md'], + sourceSnippets: [], + prTemplatePath: '.github/PULL_REQUEST_TEMPLATE.md', + prTemplate: '## Summary\n\n- [ ] Linked issue\n- [ ] Test plan', + requiredChecklistItems: ['Linked issue', 'Test plan'], + prTitleRules: ['PR title should follow feat(scope): summary'], + commitMessageRules: ['Commit message should be conventional commits.'], + branchNamingRules: ['Branch name should use feature/-slug'], + requiredValidationRules: [], + issueLinkingRules: ['PR must include linked issue'], + releaseNoteRules: [], + requiresPriorDiscussion: false, + requiresIssueLinking: true, + requiresReleaseNotes: false, + requiresPassingValidation: false, + }; + + const branchName = contributionPrService.buildPublishBranchName(issue, rules); + const commitMessage = contributionPrService.buildContributionCommitMessage(issue, rules); + const pullRequest = contributionPrService.buildDraftPullRequest(createPullRequestDraft(), issue, rules); + + expect(branchName).toMatch(/^feature\/42-fix-keyboard-focus-in-icon-only-+\d+$/); + expect(commitMessage).toStartWith('feat: address acme/widgets#42 Fix keyboard focus'); + expect(pullRequest.body).toContain('## Summary'); + expect(pullRequest.body).toContain('- [ ] Linked issue'); + expect(pullRequest.body).toContain('Closes acme/widgets#42'); + }); + test('submits a draft PR against an existing fork and reuses an open PR when present', async () => { const workspacePath = mkdtempSync(join(tmpdir(), 'openmeta-contribution-pr-')); tempDirs.push(workspacePath); diff --git a/test/llm.test.ts b/test/llm.test.ts index ea42ef4..679ca10 100644 --- a/test/llm.test.ts +++ b/test/llm.test.ts @@ -3,7 +3,13 @@ import type { StructuredOutputStatus } from '../src/contracts/index.js'; import { contextAssemblerService } from '../src/services/context-assembler.js'; import { LLMService } from '../src/services/llm.js'; import type { ImplementationDraft, MatchedIssue } from '../src/types/index.js'; -import { createIssue, createMemory, createRankedIssue, createWorkspace } from './helpers/factories.js'; +import { + createIssue, + createMemory, + createPatchDraft, + createRankedIssue, + createWorkspace, +} from './helpers/factories.js'; interface LLMServiceInternals { validateConnection(): Promise; @@ -34,6 +40,20 @@ interface LLMServiceInternals { prPotentialScore: number; }>; }>; + generatePrDraft( + issue: ReturnType, + patchDraft: ReturnType, + workspace: ReturnType, + ): Promise<{ + status: StructuredOutputStatus; + data: { + title: string; + summary: string; + changes: string[]; + validation: string[]; + risks: string[]; + }; + }>; assessIssueFeasibility( issue: ReturnType, workspace: ReturnType, @@ -399,6 +419,75 @@ describe('LLMService issue feasibility assessment', () => { }); }); +describe('LLMService PR draft generation', () => { + test('injects detected repository contribution rules into the PR prompt context', async () => { + const service = new LLMService() as unknown as LLMServiceInternals & { + initialize(apiKey: string, baseUrl: string, modelName?: string): void; + }; + const payloads: Array<{ messages: Array<{ role: string; content: string }> }> = []; + + service.initialize('sk-test', 'https://api.openai.com/v1', 'gpt-4o-mini'); + service.client = { + chat: { + completions: { + create: async (payload) => { + payloads.push(payload); + return { + choices: [ + { + message: { + content: JSON.stringify({ + version: '1', + kind: 'pull_request_draft', + status: 'success', + data: { + title: 'feat(button): add aria labels', + summary: 'Align icon-only button behavior with accessibility guidance.', + changes: ['Update button logic'], + validation: ['bun test (pending)'], + risks: [], + }, + }), + }, + }, + ], + }; + }, + }, + }, + }; + + await service.generatePrDraft( + createRankedIssue(), + createPatchDraft(), + createWorkspace({ + contributionRules: { + detectedFiles: ['CONTRIBUTING.md', '.github/PULL_REQUEST_TEMPLATE.md'], + sourceSnippets: [], + prTemplatePath: '.github/PULL_REQUEST_TEMPLATE.md', + prTemplate: '## Summary\n\n- [ ] Linked issue\n- [ ] Tests', + requiredChecklistItems: ['Linked issue', 'Tests'], + prTitleRules: ['PR title must follow feat(scope): summary'], + commitMessageRules: ['Commit must follow Conventional Commits'], + branchNamingRules: ['Branch should use feature/-slug'], + requiredValidationRules: ['Tests must pass before opening a PR'], + issueLinkingRules: ['PR must link to an issue'], + releaseNoteRules: ['Include release note when behavior changes'], + requiresPriorDiscussion: false, + requiresIssueLinking: true, + requiresReleaseNotes: true, + requiresPassingValidation: true, + }, + }), + ); + + expect(payloads[0]?.messages[1]?.content).toContain('Repository Contribution Rules:'); + expect(payloads[0]?.messages[1]?.content).toContain('Detected Rule Files: CONTRIBUTING.md'); + expect(payloads[0]?.messages[1]?.content).toContain('PR Template (.github/PULL_REQUEST_TEMPLATE.md):'); + expect(payloads[0]?.messages[1]?.content).toContain('Issue Linking Required: yes'); + }); +}); + describe('LLMService implementation draft parsing', () => { test('parses raw JSON responses into file change drafts', () => { const service = new LLMService() as unknown as LLMServiceInternals; diff --git a/test/skill-bundle.test.ts b/test/skill-bundle.test.ts index 4a49643..5bc17b9 100644 --- a/test/skill-bundle.test.ts +++ b/test/skill-bundle.test.ts @@ -104,6 +104,9 @@ describe('skill bundle rendering', () => { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'inherit'], encoding: 'utf-8', + // Windows package managers are .cmd shims, which cannot be launched + // directly by execFileSync under Bun. + shell: process.platform === 'win32', }), ) as Array<{ filename: string }>; execFileSync('tar', ['-xzf', join(packedRoot, packed[0]!.filename), '-C', packedRoot]); diff --git a/test/workspace.test.ts b/test/workspace.test.ts index c7a8964..adaf72c 100644 --- a/test/workspace.test.ts +++ b/test/workspace.test.ts @@ -202,7 +202,26 @@ describe('workspaceService.detectTestCommands', () => { await seedGit.addConfig('user.name', 'OpenMeta Test'); await seedGit.addConfig('user.email', 'openmeta@example.com'); mkdirSync(join(seedPath, 'src'), { recursive: true }); + mkdirSync(join(seedPath, '.github'), { recursive: true }); writeFileSync(join(seedPath, 'README.md'), '# Demo\n\nMissing setup notes.\n', 'utf-8'); + writeFileSync( + join(seedPath, 'CONTRIBUTING.md'), + [ + '# Contributing', + '', + '- PR title must follow `feat(scope): summary`.', + '- Commit message must follow Conventional Commits.', + '- Branch name should use feature/-slug format.', + '- PR must include linked issue and release note.', + '- Tests must pass before opening a PR.', + ].join('\n'), + 'utf-8', + ); + writeFileSync( + join(seedPath, '.github', 'PULL_REQUEST_TEMPLATE.md'), + ['## Summary', '', '- [ ] Linked issue', '- [ ] Test plan', '- [ ] Release note'].join('\n'), + 'utf-8', + ); writeFileSync( join(seedPath, 'package.json'), JSON.stringify({ @@ -232,6 +251,14 @@ describe('workspaceService.detectTestCommands', () => { candidateFiles: string[]; snippets: Array<{ path: string; content: string }>; testCommands: Array<{ command: string }>; + contributionRules?: { + detectedFiles: string[]; + prTemplatePath?: string; + requiredChecklistItems: string[]; + requiresIssueLinking: boolean; + requiresReleaseNotes: boolean; + requiresPassingValidation: boolean; + }; }>; }; const originalBuildRepoUrl = service.buildRepoUrl; @@ -254,6 +281,16 @@ describe('workspaceService.detectTestCommands', () => { expect(workspace.candidateFiles.map(normalizePathForAssertion)).toContain('src/index.ts'); expect(workspace.snippets.some((snippet) => snippet.path === 'README.md')).toBe(true); expect(workspace.testCommands.map((command) => command.command)).toContain('bun run test'); + expect(workspace.contributionRules?.detectedFiles).toContain('CONTRIBUTING.md'); + expect(workspace.contributionRules?.prTemplatePath).toBe('.github/PULL_REQUEST_TEMPLATE.md'); + expect(workspace.contributionRules?.requiredChecklistItems).toEqual([ + 'Linked issue', + 'Test plan', + 'Release note', + ]); + expect(workspace.contributionRules?.requiresIssueLinking).toBe(true); + expect(workspace.contributionRules?.requiresReleaseNotes).toBe(true); + expect(workspace.contributionRules?.requiresPassingValidation).toBe(true); } finally { service.buildRepoUrl = originalBuildRepoUrl; delete process.env['OPENMETA_HOME'];