Skip to content

Commit d07939c

Browse files
committed
feat(review): infer addressed findings from follow-up diffs
1 parent 0ed566c commit d07939c

7 files changed

Lines changed: 352 additions & 26 deletions

File tree

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
2323
## 1. Feedback, Memory, and Outcomes
2424

2525
1. [x] Add first-class comment outcome states beyond thumbs: `new`, `accepted`, `rejected`, `addressed`, `stale`, `auto_fixed`.
26-
2. [ ] Infer "addressed by later commit" by diffing follow-up pushes against the original commented lines.
26+
2. [x] Infer "addressed by later commit" by diffing follow-up pushes against the original commented lines.
2727
3. [ ] Feed addressed/not-addressed outcomes into the reinforcement store alongside thumbs.
2828
4. [x] Separate false-positive rejections from "valid but won't fix" dismissals in stored feedback.
2929
5. [ ] Weight reinforcement by reviewer role or trust level when GitHub identity is available.

src/core/comment.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ use summary::{
3737
use tags::extract_tags;
3838

3939
pub use identity::compute_comment_id;
40-
pub use outcomes::derive_comment_outcomes;
40+
pub use outcomes::{derive_comment_outcomes, infer_addressed_by_follow_up_comments};
4141
pub use types::{
42-
Category, CodeSuggestion, Comment, CommentOutcome, CommentStatus, FixEffort, FixLoopTelemetry,
43-
MergeReadiness, RawComment, ReviewCompletenessSummary, ReviewSummary, ReviewVerificationState,
44-
ReviewVerificationSummary, Severity,
42+
Category, CodeSuggestion, Comment, CommentOutcome, CommentOutcomeContext, CommentStatus,
43+
FixEffort, FixLoopTelemetry, MergeReadiness, RawComment, ReviewCompletenessSummary,
44+
ReviewSummary, ReviewVerificationState, ReviewVerificationSummary, Severity,
4545
};
4646

4747
pub struct CommentSynthesizer;

src/core/comment/outcomes.rs

Lines changed: 135 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,62 @@
1-
use super::{Comment, CommentOutcome, CommentStatus};
1+
use std::collections::{HashMap, HashSet};
2+
use std::path::Path;
23

3-
pub fn derive_comment_outcomes(comment: &Comment, stale_review: bool) -> Vec<CommentOutcome> {
4+
use crate::core::{diff_parser::ChangeType, UnifiedDiff};
5+
6+
use super::{Comment, CommentOutcome, CommentOutcomeContext, CommentStatus};
7+
8+
fn push_unique(outcomes: &mut Vec<CommentOutcome>, outcome: CommentOutcome) {
9+
if !outcomes.contains(&outcome) {
10+
outcomes.push(outcome);
11+
}
12+
}
13+
14+
fn normalize_comment_path(path: &Path) -> String {
15+
path.to_string_lossy()
16+
.replace('\\', "/")
17+
.trim_start_matches("./")
18+
.to_string()
19+
}
20+
21+
pub fn infer_addressed_by_follow_up_comments(
22+
comments: &[Comment],
23+
follow_up_diffs: &[UnifiedDiff],
24+
) -> HashSet<String> {
25+
let mut changed_old_lines_by_path: HashMap<String, HashSet<usize>> = HashMap::new();
26+
27+
for diff in follow_up_diffs {
28+
let changed_old_lines = changed_old_lines_by_path
29+
.entry(normalize_comment_path(diff.file_path.as_path()))
30+
.or_default();
31+
32+
for hunk in &diff.hunks {
33+
for line in &hunk.changes {
34+
if line.change_type != ChangeType::Context {
35+
if let Some(old_line_no) = line.old_line_no {
36+
changed_old_lines.insert(old_line_no);
37+
}
38+
}
39+
}
40+
}
41+
}
42+
43+
comments
44+
.iter()
45+
.filter(|comment| comment.status == CommentStatus::Open)
46+
.filter_map(|comment| {
47+
let path = normalize_comment_path(comment.file_path.as_path());
48+
changed_old_lines_by_path
49+
.get(&path)
50+
.filter(|changed_lines| changed_lines.contains(&comment.line_number))
51+
.map(|_| comment.id.clone())
52+
})
53+
.collect()
54+
}
55+
56+
pub fn derive_comment_outcomes(
57+
comment: &Comment,
58+
context: CommentOutcomeContext,
59+
) -> Vec<CommentOutcome> {
460
let mut outcomes = Vec::new();
561

662
match comment
@@ -10,31 +66,40 @@ pub fn derive_comment_outcomes(comment: &Comment, stale_review: bool) -> Vec<Com
1066
.map(str::to_ascii_lowercase)
1167
.as_deref()
1268
{
13-
Some("accept") => outcomes.push(CommentOutcome::Accepted),
14-
Some("reject") => outcomes.push(CommentOutcome::Rejected),
69+
Some("accept") => push_unique(&mut outcomes, CommentOutcome::Accepted),
70+
Some("reject") => push_unique(&mut outcomes, CommentOutcome::Rejected),
1571
_ => {}
1672
}
1773

18-
if comment.status == CommentStatus::Resolved {
19-
outcomes.push(CommentOutcome::Addressed);
74+
if comment.status == CommentStatus::Resolved || context.addressed_by_follow_up {
75+
push_unique(&mut outcomes, CommentOutcome::Addressed);
76+
}
77+
78+
if context.auto_fixed {
79+
push_unique(&mut outcomes, CommentOutcome::Addressed);
80+
push_unique(&mut outcomes, CommentOutcome::AutoFixed);
2081
}
2182

22-
if comment.status == CommentStatus::Open && stale_review {
23-
outcomes.push(CommentOutcome::Stale);
83+
if comment.status == CommentStatus::Open && context.stale_review {
84+
push_unique(&mut outcomes, CommentOutcome::Stale);
2485
}
2586

2687
if outcomes.is_empty() && comment.status == CommentStatus::Open {
27-
outcomes.push(CommentOutcome::New);
88+
push_unique(&mut outcomes, CommentOutcome::New);
2889
}
2990

3091
outcomes
3192
}
3293

3394
#[cfg(test)]
3495
mod tests {
96+
use std::collections::HashSet;
3597
use std::path::PathBuf;
3698

37-
use crate::core::comment::{Category, Comment, FixEffort, Severity};
99+
use crate::core::{
100+
comment::{Category, Comment, CommentOutcomeContext, FixEffort, Severity},
101+
DiffParser,
102+
};
38103

39104
use super::*;
40105

@@ -61,7 +126,7 @@ mod tests {
61126
#[test]
62127
fn derives_new_for_open_comments_without_other_signals() {
63128
assert_eq!(
64-
derive_comment_outcomes(&make_comment(), false),
129+
derive_comment_outcomes(&make_comment(), CommentOutcomeContext::default()),
65130
vec![CommentOutcome::New]
66131
);
67132
}
@@ -73,7 +138,7 @@ mod tests {
73138
comment.status = CommentStatus::Resolved;
74139

75140
assert_eq!(
76-
derive_comment_outcomes(&comment, false),
141+
derive_comment_outcomes(&comment, CommentOutcomeContext::default()),
77142
vec![CommentOutcome::Accepted, CommentOutcome::Addressed]
78143
);
79144
}
@@ -84,24 +149,79 @@ mod tests {
84149
comment.feedback = Some("reject".to_string());
85150

86151
assert_eq!(
87-
derive_comment_outcomes(&comment, false),
152+
derive_comment_outcomes(&comment, CommentOutcomeContext::default()),
88153
vec![CommentOutcome::Rejected]
89154
);
90155
}
91156

92157
#[test]
93158
fn derives_stale_for_open_comments_in_stale_reviews() {
94159
assert_eq!(
95-
derive_comment_outcomes(&make_comment(), true),
160+
derive_comment_outcomes(
161+
&make_comment(),
162+
CommentOutcomeContext {
163+
stale_review: true,
164+
..CommentOutcomeContext::default()
165+
}
166+
),
96167
vec![CommentOutcome::Stale]
97168
);
98169
}
99170

171+
#[test]
172+
fn derives_addressed_for_open_comments_touched_by_follow_up_commits() {
173+
assert_eq!(
174+
derive_comment_outcomes(
175+
&make_comment(),
176+
CommentOutcomeContext {
177+
addressed_by_follow_up: true,
178+
..CommentOutcomeContext::default()
179+
}
180+
),
181+
vec![CommentOutcome::Addressed]
182+
);
183+
}
184+
100185
#[test]
101186
fn dismissed_comments_keep_lifecycle_without_derived_outcomes() {
102187
let mut comment = make_comment();
103188
comment.status = CommentStatus::Dismissed;
104189

105-
assert!(derive_comment_outcomes(&comment, false).is_empty());
190+
assert!(derive_comment_outcomes(&comment, CommentOutcomeContext::default()).is_empty());
191+
}
192+
193+
#[test]
194+
fn infer_follow_up_marks_open_comments_as_addressed_when_old_line_changes() {
195+
let diffs = DiffParser::parse_text_diff(
196+
"first\nsecond\nthird\n",
197+
"first\nupdated second\nthird\n",
198+
PathBuf::from("src/lib.rs"),
199+
)
200+
.unwrap();
201+
202+
let mut comment = make_comment();
203+
comment.line_number = 2;
204+
205+
assert_eq!(
206+
infer_addressed_by_follow_up_comments(&[comment], &[diffs])
207+
.into_iter()
208+
.collect::<HashSet<_>>(),
209+
HashSet::from(["comment-1".to_string()])
210+
);
211+
}
212+
213+
#[test]
214+
fn infer_follow_up_ignores_context_only_line_matches() {
215+
let diffs = DiffParser::parse_text_diff(
216+
"first\nsecond\nthird\n",
217+
"first\ninserted\nsecond\nthird\n",
218+
PathBuf::from("src/lib.rs"),
219+
)
220+
.unwrap();
221+
222+
let mut comment = make_comment();
223+
comment.line_number = 2;
224+
225+
assert!(infer_addressed_by_follow_up_comments(&[comment], &[diffs]).is_empty());
106226
}
107227
}

src/core/comment/types.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ pub enum CommentOutcome {
133133
AutoFixed,
134134
}
135135

136+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
137+
pub struct CommentOutcomeContext {
138+
pub stale_review: bool,
139+
pub addressed_by_follow_up: bool,
140+
pub auto_fixed: bool,
141+
}
142+
136143
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
137144
pub enum MergeReadiness {
138145
Ready,

0 commit comments

Comments
 (0)