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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
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
143 changes: 143 additions & 0 deletions __tests__/config-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import * as fs from 'fs/promises'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
formatCodingGuidelines,
getDefaultConfig,
mergeConfigs,
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()
expect(config.codingGuidelines).toEqual([
'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.')
})
})

describe('mergeConfigs', () => {
it('should merge configs with user config overriding defaults', () => {
const defaultConfig = {
codingGuidelines: ['Default 1', 'Default 2'],
reviewSettings: {
setting1: 'default1',
setting2: 'default2'
}
}

const userConfig = {
codingGuidelines: ['User 1', 'User 2'],
reviewSettings: {
setting1: 'user1'
}
}

const merged = mergeConfigs(defaultConfig, userConfig)
expect(merged).toEqual({
codingGuidelines: ['User 1', 'User 2'],
reviewSettings: {
setting1: 'user1',
setting2: 'default2'
}
})
})

it('should handle null or undefined user config', () => {
const defaultConfig = getDefaultConfig()

expect(
mergeConfigs(defaultConfig, null as Partial<typeof defaultConfig>)
).toEqual(defaultConfig)

expect(
mergeConfigs(defaultConfig, undefined as Partial<typeof defaultConfig>)
).toEqual(defaultConfig)
})
})
})
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
"@anthropic-ai/sdk": "^0.40.0",
"dotenv": "^16.4.7",
"handlebars": "^4.7.8",
"js-yaml": "^4.1.0",
"probot": "^13.4.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
170 changes: 170 additions & 0 deletions src/config-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import * as fs from 'fs/promises'
import * as yaml from 'js-yaml'
import * as path from 'path'

/**
* 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
return mergeConfigs(DEFAULT_CONFIG, config)
} 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')
}

/**
* Merges two configuration objects, with the user config overriding the default config
* for any keys that exist in both.
*
* @param defaultConfig - The default configuration object
* @param userConfig - The user configuration object
* @returns The merged configuration object
*/
export function mergeConfigs(
defaultConfig: CodingGuidelinesConfig,
userConfig: Partial<CodingGuidelinesConfig>
): CodingGuidelinesConfig {
// Start with a copy of the default config
const result = { ...defaultConfig }

// If userConfig is not an object or is null, return the default
if (!userConfig || typeof userConfig !== 'object') {
return result
}

// Iterate through the keys in the user config
for (const key in userConfig) {
const userValue = userConfig[key]
const defaultValue = defaultConfig[key]

// If both values are objects, recursively merge them
if (
userValue &&
typeof userValue === 'object' &&
!Array.isArray(userValue) &&
defaultValue &&
typeof defaultValue === 'object' &&
!Array.isArray(defaultValue)
) {
// Use type assertion to handle nested objects
result[key] = mergeConfigs(
defaultValue as CodingGuidelinesConfig,
userValue as Partial<CodingGuidelinesConfig>
)
} else {
// Otherwise, use the user value
result[key] = userValue
}
}

return result
}

/**
* 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)
}
Loading