Skip to content

Commit 90973e5

Browse files
committed
Add pattern repo context, discuss command, and multi-hop graph retrieval
1 parent cbada52 commit 90973e5

File tree

7 files changed

+876
-7
lines changed

7 files changed

+876
-7
lines changed

.diffscope.yml.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ symbol_index_lsp_languages:
3232
symbol_index_max_files: 500
3333
symbol_index_max_bytes: 200000
3434
symbol_index_max_locations: 5
35+
symbol_index_graph_hops: 2
36+
symbol_index_graph_max_files: 12
3537
feedback_path: ".diffscope.feedback.json"
3638

3739
# Optional Greptile-like scoped context bundles.
@@ -45,6 +47,16 @@ custom_context:
4547
- "docs/security/*.md"
4648
- "src/config/**/*.yml"
4749

50+
# Cross-repo pattern libraries (local path or git URL).
51+
pattern_repositories:
52+
- source: "../shared-review-patterns"
53+
scope: "src/**"
54+
include_patterns:
55+
- "rules/**/*.md"
56+
- "examples/**/*.yml"
57+
max_files: 8
58+
max_lines: 200
59+
4860
# API configuration (optional - can use environment variables)
4961
# api_key: your-api-key-here
5062
# base_url: https://api.openai.com/v1

FEATURES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,7 @@ This enhanced system provides professional-grade code review capabilities while
109109
- `strictness` levels (1-3) to control review depth and noise.
110110
- `comment_types` filtering (`logic`, `syntax`, `style`, `informational`) to focus output.
111111
- Scoped `custom_context` entries to inject path-specific notes and context files.
112+
- `pattern_repositories` support for shared cross-repo context packs (local or git-cloned).
112113
- Adaptive suppression of repeated low-value comment types based on accepted/rejected feedback history.
114+
- `discuss` command for threaded follow-up Q&A on generated review comments.
115+
- Multi-hop symbol context via dependency graph expansion (`symbol_index_graph_hops`, `symbol_index_graph_max_files`).

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ A composable code review engine for automated diff analysis.
1818
- **Signal Controls**: Tune strictness and comment types (`logic`, `syntax`, `style`, `informational`)
1919
- **Adaptive Learning**: Suppress low-value recurring feedback based on accepted/rejected review history
2020
- **Scoped Custom Context**: Attach rules and reference files to path scopes for higher-precision reviews
21+
- **Pattern Repositories**: Pull review context from shared cross-repo rule libraries
22+
- **Comment Follow-Ups**: Ask threaded questions on generated review comments with `diffscope discuss`
2123
- **Changelog Generation**: Generate changelogs and release notes from git history
2224
- **Interactive Commands**: Respond to PR comments with @diffscope commands
2325

@@ -165,6 +167,9 @@ git diff | diffscope review --output-format markdown > review.md
165167

166168
# Inline patch comments
167169
git diff | diffscope review --output-format patch
170+
171+
# Follow-up Q&A on generated comments
172+
diffscope discuss --review review.json --comment-index 1 --question "Is this still an issue if we add caching?"
168173
```
169174

170175
## GitHub Action
@@ -215,6 +220,8 @@ symbol_index_lsp_languages:
215220
symbol_index_max_files: 500
216221
symbol_index_max_bytes: 200000
217222
symbol_index_max_locations: 5
223+
symbol_index_graph_hops: 2
224+
symbol_index_graph_max_files: 12
218225
feedback_path: ".diffscope.feedback.json"
219226
system_prompt: "Focus on security vulnerabilities, performance issues, and best practices"
220227
openai_use_responses: true # Use OpenAI Responses API (recommended) instead of chat completions
@@ -227,6 +234,15 @@ custom_context:
227234
- "docs/security/*.md"
228235
- "src/config/**/*.yml"
229236
237+
pattern_repositories:
238+
- source: "../shared-review-patterns" # local path or git URL
239+
scope: "src/**"
240+
include_patterns:
241+
- "rules/**/*.md"
242+
- "examples/**/*.yml"
243+
max_files: 8
244+
max_lines: 200
245+
230246
# Built-in plugins (enabled by default)
231247
plugins:
232248
eslint: true # JavaScript/TypeScript linting

src/config.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ pub struct Config {
5656
#[serde(default = "default_symbol_index_max_locations")]
5757
pub symbol_index_max_locations: usize,
5858

59+
#[serde(default = "default_symbol_index_graph_hops")]
60+
pub symbol_index_graph_hops: usize,
61+
62+
#[serde(default = "default_symbol_index_graph_max_files")]
63+
pub symbol_index_graph_max_files: usize,
64+
5965
#[serde(default)]
6066
pub symbol_index_lsp_command: Option<String>,
6167

@@ -83,6 +89,9 @@ pub struct Config {
8389

8490
#[serde(default)]
8591
pub custom_context: Vec<CustomContextConfig>,
92+
93+
#[serde(default)]
94+
pub pattern_repositories: Vec<PatternRepositoryConfig>,
8695
}
8796

8897
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -116,6 +125,23 @@ pub struct CustomContextConfig {
116125
pub files: Vec<String>,
117126
}
118127

128+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
129+
pub struct PatternRepositoryConfig {
130+
pub source: String,
131+
132+
#[serde(default)]
133+
pub scope: Option<String>,
134+
135+
#[serde(default)]
136+
pub include_patterns: Vec<String>,
137+
138+
#[serde(default = "default_pattern_repo_max_files")]
139+
pub max_files: usize,
140+
141+
#[serde(default = "default_pattern_repo_max_lines")]
142+
pub max_lines: usize,
143+
}
144+
119145
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120146
pub struct PluginConfig {
121147
#[serde(default = "default_true")]
@@ -148,6 +174,8 @@ impl Default for Config {
148174
symbol_index_max_files: default_symbol_index_max_files(),
149175
symbol_index_max_bytes: default_symbol_index_max_bytes(),
150176
symbol_index_max_locations: default_symbol_index_max_locations(),
177+
symbol_index_graph_hops: default_symbol_index_graph_hops(),
178+
symbol_index_graph_max_files: default_symbol_index_graph_max_files(),
151179
symbol_index_lsp_command: None,
152180
symbol_index_lsp_languages: default_symbol_index_lsp_languages(),
153181
feedback_path: default_feedback_path(),
@@ -159,6 +187,7 @@ impl Default for Config {
159187
exclude_patterns: Vec::new(),
160188
paths: HashMap::new(),
161189
custom_context: Vec::new(),
190+
pattern_repositories: Vec::new(),
162191
}
163192
}
164193
}
@@ -226,6 +255,12 @@ impl Config {
226255
if self.symbol_index_max_locations == 0 {
227256
self.symbol_index_max_locations = default_symbol_index_max_locations();
228257
}
258+
if self.symbol_index_graph_hops == 0 {
259+
self.symbol_index_graph_hops = default_symbol_index_graph_hops();
260+
}
261+
if self.symbol_index_graph_max_files == 0 {
262+
self.symbol_index_graph_max_files = default_symbol_index_graph_max_files();
263+
}
229264

230265
let provider = self.symbol_index_provider.trim().to_lowercase();
231266
if provider.is_empty() || !matches!(provider.as_str(), "regex" | "lsp") {
@@ -304,6 +339,40 @@ impl Config {
304339
normalized_custom_context.push(entry);
305340
}
306341
self.custom_context = normalized_custom_context;
342+
343+
let mut normalized_pattern_repositories = Vec::new();
344+
for mut repo in std::mem::take(&mut self.pattern_repositories) {
345+
repo.source = repo.source.trim().to_string();
346+
if repo.source.is_empty() {
347+
continue;
348+
}
349+
repo.scope = repo.scope.and_then(|scope| {
350+
let trimmed = scope.trim().to_string();
351+
if trimmed.is_empty() {
352+
None
353+
} else {
354+
Some(trimmed)
355+
}
356+
});
357+
repo.include_patterns = repo
358+
.include_patterns
359+
.into_iter()
360+
.map(|pattern| pattern.trim().to_string())
361+
.filter(|pattern| !pattern.is_empty())
362+
.collect();
363+
if repo.include_patterns.is_empty() {
364+
repo.include_patterns.push("**/*".to_string());
365+
}
366+
if repo.max_files == 0 {
367+
repo.max_files = default_pattern_repo_max_files();
368+
}
369+
if repo.max_lines == 0 {
370+
repo.max_lines = default_pattern_repo_max_lines();
371+
}
372+
373+
normalized_pattern_repositories.push(repo);
374+
}
375+
self.pattern_repositories = normalized_pattern_repositories;
307376
}
308377

309378
pub fn get_path_config(&self, file_path: &Path) -> Option<&PathConfig> {
@@ -366,6 +435,17 @@ impl Config {
366435
self.min_confidence.max(strictness_floor).clamp(0.0, 1.0)
367436
}
368437

438+
pub fn matching_pattern_repositories(&self, file_path: &Path) -> Vec<&PatternRepositoryConfig> {
439+
let file_path_str = file_path.to_string_lossy();
440+
self.pattern_repositories
441+
.iter()
442+
.filter(|repo| match repo.scope.as_deref() {
443+
Some(scope) => self.path_matches(&file_path_str, scope),
444+
None => true,
445+
})
446+
.collect()
447+
}
448+
369449
fn path_matches(&self, path: &str, pattern: &str) -> bool {
370450
// Simple glob matching
371451
if pattern.contains('*') {
@@ -470,6 +550,14 @@ fn default_symbol_index_max_locations() -> usize {
470550
5
471551
}
472552

553+
fn default_symbol_index_graph_hops() -> usize {
554+
2
555+
}
556+
557+
fn default_symbol_index_graph_max_files() -> usize {
558+
12
559+
}
560+
473561
fn default_symbol_index_provider() -> String {
474562
"regex".to_string()
475563
}
@@ -484,6 +572,14 @@ fn default_feedback_path() -> PathBuf {
484572
PathBuf::from(".diffscope.feedback.json")
485573
}
486574

575+
fn default_pattern_repo_max_files() -> usize {
576+
8
577+
}
578+
579+
fn default_pattern_repo_max_lines() -> usize {
580+
200
581+
}
582+
487583
fn default_true() -> bool {
488584
true
489585
}

src/core/context.rs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ impl ContextFetcher {
7474
pub async fn fetch_additional_context(
7575
&self,
7676
patterns: &[String],
77+
) -> Result<Vec<LLMContextChunk>> {
78+
self.fetch_additional_context_from_base(&self.repo_path, patterns, 10, 200)
79+
.await
80+
}
81+
82+
pub async fn fetch_additional_context_from_base(
83+
&self,
84+
base_path: &Path,
85+
patterns: &[String],
86+
max_files: usize,
87+
max_lines: usize,
7788
) -> Result<Vec<LLMContextChunk>> {
7889
let mut chunks = Vec::new();
7990
if patterns.is_empty() {
@@ -85,7 +96,7 @@ impl ContextFetcher {
8596
let pattern_path = if Path::new(pattern).is_absolute() {
8697
pattern.clone()
8798
} else {
88-
self.repo_path.join(pattern).to_string_lossy().to_string()
99+
base_path.join(pattern).to_string_lossy().to_string()
89100
};
90101

91102
if let Ok(entries) = glob(&pattern_path) {
@@ -97,11 +108,8 @@ impl ContextFetcher {
97108
}
98109
}
99110

100-
let max_files = 10usize;
101-
let max_lines = 200usize;
102-
103111
for path in matched_paths.into_iter().take(max_files) {
104-
let relative_path = path.strip_prefix(&self.repo_path).unwrap_or(&path);
112+
let relative_path = path.strip_prefix(base_path).unwrap_or(&path);
105113
let content = read_file_lossy(&path).await?;
106114
let snippet = content
107115
.lines()
@@ -182,6 +190,8 @@ impl ContextFetcher {
182190
symbols: &[String],
183191
index: &SymbolIndex,
184192
max_locations: usize,
193+
graph_hops: usize,
194+
graph_max_files: usize,
185195
) -> Result<Vec<LLMContextChunk>> {
186196
let mut chunks = Vec::new();
187197

@@ -206,6 +216,25 @@ impl ContextFetcher {
206216
}
207217
}
208218

219+
for location in index.multi_hop_locations(
220+
file_path,
221+
symbols,
222+
max_locations,
223+
graph_hops,
224+
graph_max_files,
225+
) {
226+
if &location.file_path == file_path {
227+
continue;
228+
}
229+
let snippet = truncate_with_notice(location.snippet, MAX_CONTEXT_CHARS);
230+
chunks.push(LLMContextChunk {
231+
file_path: location.file_path,
232+
content: snippet,
233+
context_type: ContextType::Reference,
234+
line_range: Some(location.line_range),
235+
});
236+
}
237+
209238
Ok(chunks)
210239
}
211240
}

0 commit comments

Comments
 (0)