Skip to content

Commit 061060c

Browse files
committed
feat(config): Add .revu.yml config file and its loader
vidy-br: revu-config-file
1 parent 1eeb05a commit 061060c

File tree

6 files changed

+364
-1
lines changed

6 files changed

+364
-1
lines changed

.revu.yml

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Revu Configuration File
2+
3+
# Coding guidelines for code reviews
4+
codingGuidelines:
5+
- "Test coverage: Critical code requires 100% test coverage; non-critical paths require 60% coverage."
6+
- "Naming: Use semantically significant names for functions, classes, and parameters."
7+
- "Comments: Add comments only for complex code; simple code should be self-explanatory."
8+
- "Documentation: Public functions must have concise docstrings explaining purpose and return values."
9+
10+
# Review settings (for future use)
11+
reviewSettings:
12+
# Future configuration options can be added here

README.md

+28-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ Revu is a GitHub App that leverages Anthropic's Claude AI to provide intelligent
88
- **Intelligent Feedback**: Provides detailed explanations and suggestions for improvements
99
- **Git-Aware**: Considers commit history and branch differences
1010
- **GitHub Integration**: Seamlessly integrates with GitHub's PR workflow
11-
- **Customizable**: Configurable through environment variables and templates
11+
- **Customizable**: Configurable through environment variables, templates, and coding guidelines
12+
- **Coding Guidelines**: Enforce custom coding standards through configuration
1213

1314
## How It Works
1415

@@ -185,11 +186,37 @@ extractLogFromRepo({
185186

186187
### Configuration
187188

189+
#### Model Configuration
190+
188191
- Model: Claude 3 Sonnet
189192
- Max tokens: 4096
190193
- Temperature: 0.7
191194
- Required env: `ANTHROPIC_API_KEY`
192195

196+
#### Coding Guidelines Configuration
197+
198+
Revu supports custom coding guidelines through a `.revu.yml` YAML configuration file in the project root:
199+
200+
```yaml
201+
# .revu.yml file structure
202+
codingGuidelines:
203+
- "Test coverage: Critical code requires 100% test coverage; non-critical paths require 60% coverage."
204+
- "Naming: Use semantically significant names for functions, classes, and parameters."
205+
- "Comments: Add comments only for complex code; simple code should be self-explanatory."
206+
- "Documentation: Public functions must have concise docstrings explaining purpose and return values."
207+
208+
reviewSettings:
209+
# Future configuration options for review behavior
210+
```
211+
212+
The configuration supports:
213+
214+
- **Hierarchical Structure**: Organized by configuration type
215+
- **User Overrides**: Repository-specific `.revu.yml` files can override default settings
216+
- **Extensible Design**: Ready for future configuration options
217+
218+
Guidelines are automatically included in code review comments to ensure consistent standards across your projects.
219+
193220
## Troubleshooting
194221

195222
### Common Issues

__tests__/config-handler.test.ts

+143
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import * as fs from 'fs/promises'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import {
4+
formatCodingGuidelines,
5+
getDefaultConfig,
6+
mergeConfigs,
7+
readConfig,
8+
type CodingGuidelinesConfig
9+
} from '../src/config-handler.ts'
10+
11+
// Mock fs.promises
12+
vi.mock('fs/promises', () => ({
13+
readFile: vi.fn(),
14+
access: vi.fn()
15+
}))
16+
17+
// Mock path
18+
vi.mock('path', () => ({
19+
join: vi.fn((...args) => args.join('/'))
20+
}))
21+
22+
describe('Config Handler', () => {
23+
beforeEach(() => {
24+
vi.resetAllMocks()
25+
})
26+
27+
afterEach(() => {
28+
vi.clearAllMocks()
29+
})
30+
31+
describe('readConfig', () => {
32+
it('should return default config when file does not exist', async () => {
33+
// Mock fs.access to throw an error (file doesn't exist)
34+
vi.mocked(fs.access).mockRejectedValueOnce(new Error('File not found'))
35+
36+
const config = await readConfig()
37+
expect(config).toEqual(getDefaultConfig())
38+
})
39+
40+
it('should read and parse YAML config file', async () => {
41+
// Mock file content
42+
const mockYamlContent = `
43+
codingGuidelines:
44+
- "Test guideline 1"
45+
- "Test guideline 2"
46+
reviewSettings:
47+
setting1: value1
48+
`
49+
// Mock successful file access
50+
vi.mocked(fs.access).mockResolvedValueOnce(undefined)
51+
// Mock file read
52+
vi.mocked(fs.readFile).mockResolvedValueOnce(mockYamlContent)
53+
54+
const config = await readConfig()
55+
expect(config.codingGuidelines).toEqual([
56+
'Test guideline 1',
57+
'Test guideline 2'
58+
])
59+
expect(config.reviewSettings).toEqual({ setting1: 'value1' })
60+
})
61+
62+
it('should return default config on error', async () => {
63+
// Mock fs.access to succeed but fs.readFile to fail
64+
vi.mocked(fs.access).mockResolvedValueOnce(undefined)
65+
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('Read error'))
66+
67+
const config = await readConfig()
68+
expect(config).toEqual(getDefaultConfig())
69+
})
70+
})
71+
72+
describe('formatCodingGuidelines', () => {
73+
it('should format guidelines as numbered list', () => {
74+
const config = {
75+
codingGuidelines: ['Guideline 1', 'Guideline 2', 'Guideline 3'],
76+
reviewSettings: {}
77+
}
78+
79+
const formatted = formatCodingGuidelines(config)
80+
expect(formatted).toBe('1. Guideline 1\n2. Guideline 2\n3. Guideline 3')
81+
})
82+
83+
it('should handle empty guidelines array', () => {
84+
const config = {
85+
codingGuidelines: [],
86+
reviewSettings: {}
87+
}
88+
89+
const formatted = formatCodingGuidelines(config)
90+
expect(formatted).toBe('No specific coding guidelines defined.')
91+
})
92+
93+
it('should handle missing guidelines', () => {
94+
// Create a config object without codingGuidelines
95+
const config = {
96+
reviewSettings: {}
97+
} as CodingGuidelinesConfig
98+
99+
const formatted = formatCodingGuidelines(config)
100+
expect(formatted).toBe('No specific coding guidelines defined.')
101+
})
102+
})
103+
104+
describe('mergeConfigs', () => {
105+
it('should merge configs with user config overriding defaults', () => {
106+
const defaultConfig = {
107+
codingGuidelines: ['Default 1', 'Default 2'],
108+
reviewSettings: {
109+
setting1: 'default1',
110+
setting2: 'default2'
111+
}
112+
}
113+
114+
const userConfig = {
115+
codingGuidelines: ['User 1', 'User 2'],
116+
reviewSettings: {
117+
setting1: 'user1'
118+
}
119+
}
120+
121+
const merged = mergeConfigs(defaultConfig, userConfig)
122+
expect(merged).toEqual({
123+
codingGuidelines: ['User 1', 'User 2'],
124+
reviewSettings: {
125+
setting1: 'user1',
126+
setting2: 'default2'
127+
}
128+
})
129+
})
130+
131+
it('should handle null or undefined user config', () => {
132+
const defaultConfig = getDefaultConfig()
133+
134+
expect(
135+
mergeConfigs(defaultConfig, null as Partial<typeof defaultConfig>)
136+
).toEqual(defaultConfig)
137+
138+
expect(
139+
mergeConfigs(defaultConfig, undefined as Partial<typeof defaultConfig>)
140+
).toEqual(defaultConfig)
141+
})
142+
})
143+
})

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
"@anthropic-ai/sdk": "^0.40.0",
2121
"dotenv": "^16.4.7",
2222
"handlebars": "^4.7.8",
23+
"js-yaml": "^4.1.0",
2324
"probot": "^13.4.3"
2425
},
2526
"devDependencies": {
2627
"@eslint/eslintrc": "^3.3.1",
2728
"@eslint/js": "^9.25.1",
29+
"@types/js-yaml": "^4.0.9",
2830
"@types/node": "22.15.3",
2931
"@typescript-eslint/eslint-plugin": "^8.24.1",
3032
"@typescript-eslint/parser": "^8.24.1",

src/config-handler.ts

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import * as fs from 'fs/promises'
2+
import * as yaml from 'js-yaml'
3+
import * as path from 'path'
4+
5+
/**
6+
* Check if a file exists
7+
*
8+
* @param filePath - Path to the file to check
9+
* @returns True if the file exists, false otherwise
10+
*/
11+
async function fileExists(filePath: string): Promise<boolean> {
12+
try {
13+
await fs.access(filePath)
14+
return true
15+
} catch {
16+
return false
17+
}
18+
}
19+
20+
/**
21+
* Interface for the coding guidelines configuration
22+
*/
23+
export interface CodingGuidelinesConfig {
24+
codingGuidelines: string[]
25+
reviewSettings: Record<string, unknown>
26+
[key: string]: unknown // Allow for future configuration options
27+
}
28+
29+
/**
30+
* Default coding guidelines configuration
31+
*/
32+
const DEFAULT_CONFIG: CodingGuidelinesConfig = {
33+
codingGuidelines: [
34+
'Test coverage: Critical code requires 100% test coverage; non-critical paths require 60% coverage.',
35+
'Naming: Use semantically significant names for functions, classes, and parameters.',
36+
'Comments: Add comments only for complex code; simple code should be self-explanatory.',
37+
'Documentation: Public functions must have concise docstrings explaining purpose and return values.'
38+
],
39+
reviewSettings: {
40+
// Future configuration options can be added here
41+
}
42+
}
43+
44+
/**
45+
* Reads the .revu.yml configuration file from the specified path
46+
*
47+
* @param configPath - Path to the .revu.yml configuration file
48+
* @returns The parsed configuration object
49+
*/
50+
export async function readConfig(
51+
configPath = path.join(process.cwd(), '.revu.yml')
52+
): Promise<CodingGuidelinesConfig> {
53+
try {
54+
// Check if the config file exists
55+
const exists = await fileExists(configPath)
56+
if (!exists) {
57+
console.log('No .revu.yml configuration file found, using defaults')
58+
return DEFAULT_CONFIG
59+
}
60+
61+
// Read and parse the YAML file
62+
const configContent = await fs.readFile(configPath, 'utf-8')
63+
const config = yaml.load(configContent) as CodingGuidelinesConfig
64+
65+
// Merge with default config to ensure all required fields exist
66+
return mergeConfigs(DEFAULT_CONFIG, config)
67+
} catch (error) {
68+
console.error('Error reading .revu.yml configuration file:', error)
69+
return DEFAULT_CONFIG
70+
}
71+
}
72+
73+
/**
74+
* Gets the default configuration
75+
*
76+
* @returns The default configuration object
77+
*/
78+
export function getDefaultConfig(): CodingGuidelinesConfig {
79+
return { ...DEFAULT_CONFIG }
80+
}
81+
82+
/**
83+
* Gets the coding guidelines from the configuration
84+
*
85+
* @param config - The configuration object
86+
* @returns The coding guidelines as a formatted string
87+
*/
88+
export function formatCodingGuidelines(config: CodingGuidelinesConfig): string {
89+
if (
90+
!config.codingGuidelines ||
91+
!Array.isArray(config.codingGuidelines) ||
92+
config.codingGuidelines.length === 0
93+
) {
94+
return 'No specific coding guidelines defined.'
95+
}
96+
97+
return config.codingGuidelines
98+
.map((guideline, index) => `${index + 1}. ${guideline}`)
99+
.join('\n')
100+
}
101+
102+
/**
103+
* Merges two configuration objects, with the user config overriding the default config
104+
* for any keys that exist in both.
105+
*
106+
* @param defaultConfig - The default configuration object
107+
* @param userConfig - The user configuration object
108+
* @returns The merged configuration object
109+
*/
110+
export function mergeConfigs(
111+
defaultConfig: CodingGuidelinesConfig,
112+
userConfig: Partial<CodingGuidelinesConfig>
113+
): CodingGuidelinesConfig {
114+
// Start with a copy of the default config
115+
const result = { ...defaultConfig }
116+
117+
// If userConfig is not an object or is null, return the default
118+
if (!userConfig || typeof userConfig !== 'object') {
119+
return result
120+
}
121+
122+
// Iterate through the keys in the user config
123+
for (const key in userConfig) {
124+
const userValue = userConfig[key]
125+
const defaultValue = defaultConfig[key]
126+
127+
// If both values are objects, recursively merge them
128+
if (
129+
userValue &&
130+
typeof userValue === 'object' &&
131+
!Array.isArray(userValue) &&
132+
defaultValue &&
133+
typeof defaultValue === 'object' &&
134+
!Array.isArray(defaultValue)
135+
) {
136+
// Use type assertion to handle nested objects
137+
result[key] = mergeConfigs(
138+
defaultValue as CodingGuidelinesConfig,
139+
userValue as Partial<CodingGuidelinesConfig>
140+
)
141+
} else {
142+
// Otherwise, use the user value
143+
result[key] = userValue
144+
}
145+
}
146+
147+
return result
148+
}
149+
150+
/**
151+
* Gets the coding guidelines from the configuration
152+
*
153+
* @param repoPath - Optional path to the repository
154+
* @returns The coding guidelines as a formatted string
155+
*/
156+
export async function getCodingGuidelines(repoPath?: string): Promise<string> {
157+
let configPath = path.join(process.cwd(), '.revu.yml')
158+
159+
// If a repository path is provided, check for a .revu.yml file there
160+
if (repoPath) {
161+
const repoConfigPath = path.join(repoPath, '.revu.yml')
162+
const exists = await fileExists(repoConfigPath)
163+
if (exists) {
164+
configPath = repoConfigPath
165+
}
166+
}
167+
168+
const config = await readConfig(configPath)
169+
return formatCodingGuidelines(config)
170+
}

0 commit comments

Comments
 (0)