Skip to content

feat(config): Add .revu.yml config file and its loader #30

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 13, 2025
Merged
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
12 changes: 12 additions & 0 deletions .revu.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Revu Configuration File

# Coding guidelines for code reviews
codingGuidelines:
- "Test coverage: Critical code requires 100% test coverage; non-critical paths require 60% coverage."
- "Naming: Use semantically significant names for functions, classes, and parameters."
- "Comments: Add comments only for complex code; simple code should be self-explanatory."
- "Documentation: Public functions must have concise docstrings explaining purpose and return values."

# Review settings (for future use)
reviewSettings:
# Future configuration options can be added here
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Revu is a GitHub App that leverages Anthropic's Claude AI to provide intelligent
- **Intelligent Feedback**: Provides detailed explanations and suggestions for improvements
- **Git-Aware**: Considers commit history and branch differences
- **GitHub Integration**: Seamlessly integrates with GitHub's PR workflow
- **Customizable**: Configurable through environment variables and templates
- **Customizable**: Configurable through environment variables, templates, and coding guidelines
- **Coding Guidelines**: Enforce custom coding standards through configuration

## How It Works

Expand Down Expand Up @@ -185,11 +186,37 @@ extractLogFromRepo({

### Configuration

#### Model Configuration

- Model: Claude 3 Sonnet
- Max tokens: 4096
- Temperature: 0.7
- Required env: `ANTHROPIC_API_KEY`

#### Coding Guidelines Configuration

Revu supports custom coding guidelines through a `.revu.yml` YAML configuration file in the project root:

```yaml
# .revu.yml file structure
codingGuidelines:
- "Test coverage: Critical code requires 100% test coverage; non-critical paths require 60% coverage."
- "Naming: Use semantically significant names for functions, classes, and parameters."
- "Comments: Add comments only for complex code; simple code should be self-explanatory."
- "Documentation: Public functions must have concise docstrings explaining purpose and return values."

reviewSettings:
# Future configuration options for review behavior
```

The configuration supports:

- **Hierarchical Structure**: Organized by configuration type
- **User Overrides**: Repository-specific `.revu.yml` files can override default settings
- **Extensible Design**: Ready for future configuration options

Guidelines are automatically included in code review comments to ensure consistent standards across your projects.

## Troubleshooting

### Common Issues
Expand Down
110 changes: 110 additions & 0 deletions __tests__/config-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import * as fs from 'fs/promises'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
formatCodingGuidelines,
getDefaultConfig,
readConfig,
type CodingGuidelinesConfig
} from '../src/config-handler.ts'

// Mock fs.promises
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
access: vi.fn()
}))

// Mock path
vi.mock('path', () => ({
join: vi.fn((...args) => args.join('/'))
}))

describe('Config Handler', () => {
beforeEach(() => {
vi.resetAllMocks()
})

afterEach(() => {
vi.clearAllMocks()
})

describe('readConfig', () => {
it('should return default config when file does not exist', async () => {
// Mock fs.access to throw an error (file doesn't exist)
vi.mocked(fs.access).mockRejectedValueOnce(new Error('File not found'))

const config = await readConfig()
expect(config).toEqual(getDefaultConfig())
})

it('should read and parse YAML config file', async () => {
// Mock file content
const mockYamlContent = `
codingGuidelines:
- "Test guideline 1"
- "Test guideline 2"
reviewSettings:
setting1: value1
`
// Mock successful file access
vi.mocked(fs.access).mockResolvedValueOnce(undefined)
// Mock file read
vi.mocked(fs.readFile).mockResolvedValueOnce(mockYamlContent)

const config = await readConfig()

// With ts-deepmerge, arrays are concatenated
// Default guidelines + YAML file guidelines
expect(config.codingGuidelines).toEqual([
'Test coverage: Critical code requires 100% test coverage; non-critical paths require 60% coverage.',
'Naming: Use semantically significant names for functions, classes, and parameters.',
'Comments: Add comments only for complex code; simple code should be self-explanatory.',
'Documentation: Public functions must have concise docstrings explaining purpose and return values.',
'Test guideline 1',
'Test guideline 2'
])

expect(config.reviewSettings).toEqual({ setting1: 'value1' })
})

it('should return default config on error', async () => {
// Mock fs.access to succeed but fs.readFile to fail
vi.mocked(fs.access).mockResolvedValueOnce(undefined)
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('Read error'))

const config = await readConfig()
expect(config).toEqual(getDefaultConfig())
})
})

describe('formatCodingGuidelines', () => {
it('should format guidelines as numbered list', () => {
const config = {
codingGuidelines: ['Guideline 1', 'Guideline 2', 'Guideline 3'],
reviewSettings: {}
}

const formatted = formatCodingGuidelines(config)
expect(formatted).toBe('1. Guideline 1\n2. Guideline 2\n3. Guideline 3')
})

it('should handle empty guidelines array', () => {
const config = {
codingGuidelines: [],
reviewSettings: {}
}

const formatted = formatCodingGuidelines(config)
expect(formatted).toBe('No specific coding guidelines defined.')
})

it('should handle missing guidelines', () => {
// Create a config object without codingGuidelines
const config = {
reviewSettings: {}
} as CodingGuidelinesConfig

const formatted = formatCodingGuidelines(config)
expect(formatted).toBe('No specific coding guidelines defined.')
})
})
})
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@
"@anthropic-ai/sdk": "^0.40.0",
"dotenv": "^16.4.7",
"handlebars": "^4.7.8",
"js-yaml": "^4.1.0",
"probot": "^13.4.3",
"ts-deepmerge": "^7.0.3",
"zod": "^3.24.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.25.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "22.15.3",
"@typescript-eslint/eslint-plugin": "^8.24.1",
"@typescript-eslint/parser": "^8.24.1",
Expand Down
124 changes: 124 additions & 0 deletions src/config-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import * as fs from 'fs/promises'
import * as yaml from 'js-yaml'
import * as path from 'path'
import { merge } from 'ts-deepmerge'

/**
* Check if a file exists
*
* @param filePath - Path to the file to check
* @returns True if the file exists, false otherwise
*/
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}

/**
* Interface for the coding guidelines configuration
*/
export interface CodingGuidelinesConfig {
codingGuidelines: string[]
reviewSettings: Record<string, unknown>
[key: string]: unknown // Allow for future configuration options
}

/**
* Default coding guidelines configuration
*/
const DEFAULT_CONFIG: CodingGuidelinesConfig = {
codingGuidelines: [
'Test coverage: Critical code requires 100% test coverage; non-critical paths require 60% coverage.',
'Naming: Use semantically significant names for functions, classes, and parameters.',
'Comments: Add comments only for complex code; simple code should be self-explanatory.',
'Documentation: Public functions must have concise docstrings explaining purpose and return values.'
],
reviewSettings: {
// Future configuration options can be added here
}
}

/**
* Reads the .revu.yml configuration file from the specified path
*
* @param configPath - Path to the .revu.yml configuration file
* @returns The parsed configuration object
*/
export async function readConfig(
configPath = path.join(process.cwd(), '.revu.yml')
): Promise<CodingGuidelinesConfig> {
try {
// Check if the config file exists
const exists = await fileExists(configPath)
if (!exists) {
console.log('No .revu.yml configuration file found, using defaults')
return DEFAULT_CONFIG
}

// Read and parse the YAML file
const configContent = await fs.readFile(configPath, 'utf-8')
const config = yaml.load(configContent) as CodingGuidelinesConfig

// Merge with default config to ensure all required fields exist
// Use the default behavior (overwrite arrays)
return merge(DEFAULT_CONFIG, config) as CodingGuidelinesConfig
} catch (error) {
console.error('Error reading .revu.yml configuration file:', error)
return DEFAULT_CONFIG
}
}

/**
* Gets the default configuration
*
* @returns The default configuration object
*/
export function getDefaultConfig(): CodingGuidelinesConfig {
return { ...DEFAULT_CONFIG }
}

/**
* Gets the coding guidelines from the configuration
*
* @param config - The configuration object
* @returns The coding guidelines as a formatted string
*/
export function formatCodingGuidelines(config: CodingGuidelinesConfig): string {
if (
!config.codingGuidelines ||
!Array.isArray(config.codingGuidelines) ||
config.codingGuidelines.length === 0
) {
return 'No specific coding guidelines defined.'
}

return config.codingGuidelines
.map((guideline, index) => `${index + 1}. ${guideline}`)
.join('\n')
}

/**
* Gets the coding guidelines from the configuration
*
* @param repoPath - Optional path to the repository
* @returns The coding guidelines as a formatted string
*/
export async function getCodingGuidelines(repoPath?: string): Promise<string> {
let configPath = path.join(process.cwd(), '.revu.yml')

// If a repository path is provided, check for a .revu.yml file there
if (repoPath) {
const repoConfigPath = path.join(repoPath, '.revu.yml')
const exists = await fileExists(repoConfigPath)
if (exists) {
configPath = repoConfigPath
}
}

const config = await readConfig(configPath)
return formatCodingGuidelines(config)
}
17 changes: 17 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,13 @@ __metadata:
languageName: node
linkType: hard

"@types/js-yaml@npm:^4.0.9":
version: 4.0.9
resolution: "@types/js-yaml@npm:4.0.9"
checksum: 10c0/24de857aa8d61526bbfbbaa383aa538283ad17363fcd5bb5148e2c7f604547db36646440e739d78241ed008702a8920665d1add5618687b6743858fae00da211
languageName: node
linkType: hard

"@types/json-schema@npm:^7.0.15":
version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15"
Expand Down Expand Up @@ -4925,6 +4932,7 @@ __metadata:
"@anthropic-ai/sdk": "npm:^0.40.0"
"@eslint/eslintrc": "npm:^3.3.1"
"@eslint/js": "npm:^9.25.1"
"@types/js-yaml": "npm:^4.0.9"
"@types/node": "npm:22.15.3"
"@typescript-eslint/eslint-plugin": "npm:^8.24.1"
"@typescript-eslint/parser": "npm:^8.24.1"
Expand All @@ -4936,8 +4944,10 @@ __metadata:
globals: "npm:^16.0.0"
handlebars: "npm:^4.7.8"
husky: "npm:^9.1.7"
js-yaml: "npm:^4.1.0"
prettier: "npm:^3.5.1"
probot: "npm:^13.4.3"
ts-deepmerge: "npm:^7.0.3"
typescript: "npm:5.8.3"
vitest: "npm:^3.0.5"
zod: "npm:^3.24.3"
Expand Down Expand Up @@ -5494,6 +5504,13 @@ __metadata:
languageName: node
linkType: hard

"ts-deepmerge@npm:^7.0.3":
version: 7.0.3
resolution: "ts-deepmerge@npm:7.0.3"
checksum: 10c0/f466a8c5fb65c568cd45e537ab42deb032842e34fc175973edf7771a8b8b4094a2ede65af07bc287e58cb241da82f48f10fb38e41c74dc9a3951fc85b2ec1522
languageName: node
linkType: hard

"tslib@npm:^2.8.1":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
Expand Down