Skip to content

Commit 80aef89

Browse files
Fix agentdiff sync and PR attribution reporting (#7)
* fix: improve agentdiff sync and PR attribution reporting Made-with: Cursor * fix: address PR review robustness issues Made-with: Cursor
1 parent d2c9b58 commit 80aef89

6 files changed

Lines changed: 299 additions & 62 deletions

File tree

‎scripts/capture-cursor.py‎

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,80 @@ def first(payload: dict, *keys, default=None):
3838
return default
3939

4040

41+
def coerce_line(value):
42+
try:
43+
n = int(value)
44+
return n if n > 0 else None
45+
except Exception:
46+
return None
47+
48+
49+
def _add_line_range(out: set, start, end=None):
50+
start_n = coerce_line(start)
51+
end_n = coerce_line(end if end is not None else start)
52+
if start_n is None or end_n is None:
53+
return
54+
for ln in range(min(start_n, end_n), max(start_n, end_n) + 1):
55+
out.add(ln)
56+
57+
58+
def _extract_line_from_position(value):
59+
if isinstance(value, dict):
60+
return first(value, "line", "lineNumber", "line_number")
61+
return value
62+
63+
64+
def _add_lines_from_range_object(out: set, value):
65+
if not isinstance(value, dict):
66+
return
67+
68+
start = first(value, "startLine", "start_line", "line_start", "line", "lineNumber", "line_number")
69+
end = first(value, "endLine", "end_line", "line_end", default=start)
70+
if start is not None:
71+
_add_line_range(out, start, end)
72+
return
73+
74+
start_pos = _extract_line_from_position(value.get("start"))
75+
end_pos = _extract_line_from_position(value.get("end"))
76+
if start_pos is not None:
77+
_add_line_range(out, start_pos, end_pos if end_pos is not None else start_pos)
78+
79+
80+
def extract_lines(value):
81+
"""Extract 1-indexed line numbers from common Cursor hook payload shapes."""
82+
out = set()
83+
if isinstance(value, list):
84+
for item in value:
85+
if isinstance(item, dict):
86+
_add_lines_from_range_object(out, item)
87+
else:
88+
line = coerce_line(item)
89+
if line is not None:
90+
out.add(line)
91+
elif isinstance(value, dict):
92+
_add_lines_from_range_object(out, value)
93+
else:
94+
line = coerce_line(value)
95+
if line is not None:
96+
out.add(line)
97+
return sorted(out)
98+
99+
100+
def extract_lines_from_changes(changes):
101+
out = set()
102+
if not isinstance(changes, list):
103+
return []
104+
for change in changes:
105+
if not isinstance(change, dict):
106+
continue
107+
before = len(out)
108+
_add_lines_from_range_object(out, change)
109+
if len(out) == before:
110+
for key in ("range", "newRange", "new_range", "selection", "targetSelection"):
111+
_add_lines_from_range_object(out, change.get(key))
112+
return sorted(out)
113+
114+
41115
def find_repo_root(cwd: str) -> str:
42116
try:
43117
result = subprocess.run(
@@ -260,20 +334,12 @@ def main():
260334

261335
# Line numbers from payload
262336
if event_name == "afterFileEdit":
263-
old_lines = first(payload, "old_lines", "oldLines", default=[])
264-
new_lines = first(payload, "new_lines", "newLines", default=[])
265-
if not isinstance(old_lines, list):
266-
old_lines = []
267-
if not isinstance(new_lines, list):
268-
new_lines = []
269-
if not new_lines and isinstance(payload.get("changes"), list):
270-
for ch in payload["changes"]:
271-
if not isinstance(ch, dict):
272-
continue
273-
start = ch.get("startLine") or ch.get("line_start")
274-
end = ch.get("endLine") or ch.get("line_end") or start
275-
if isinstance(start, int) and isinstance(end, int):
276-
new_lines.extend(list(range(min(start, end), max(start, end) + 1)))
337+
old_lines = extract_lines(first(payload, "old_lines", "oldLines", default=[]))
338+
new_lines = extract_lines(first(payload, "new_lines", "newLines", "changed_lines", "changedLines", "lines", default=[]))
339+
if not new_lines:
340+
new_lines = extract_lines_from_changes(payload.get("changes"))
341+
if not new_lines:
342+
new_lines = extract_lines_from_changes(payload.get("edits"))
277343

278344
entry["lines"] = new_lines if new_lines else old_lines
279345
debug_log(

‎scripts/tests/test_capture_cursor.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,28 @@ def test_normalize_windows_style_path(self):
2828
normalized = self.mod.normalize_path(r"\home\prakh\repo\src\main.rs", "/tmp")
2929
self.assertEqual(normalized, "/home/prakh/repo/src/main.rs")
3030

31+
def test_extract_lines_from_cursor_change_ranges(self):
32+
payload = [
33+
{"startLine": 3, "endLine": 5},
34+
{"range": {"start": {"line": 8}, "end": {"line": 9}}},
35+
{"line_number": "12"},
36+
]
37+
self.assertEqual(self.mod.extract_lines_from_changes(payload), [3, 4, 5, 8, 9, 12])
38+
39+
def test_extract_lines_prefers_top_level_range_over_nested_selection(self):
40+
payload = [
41+
{
42+
"startLine": 10,
43+
"endLine": 10,
44+
"selection": {"start": {"line": 5}, "end": {"line": 20}},
45+
}
46+
]
47+
self.assertEqual(self.mod.extract_lines_from_changes(payload), [10])
48+
49+
def test_extract_lines_accepts_mixed_line_lists(self):
50+
payload = [2, "4", {"start_line": 7, "end_line": 8}, 0, "nope"]
51+
self.assertEqual(self.mod.extract_lines(payload), [2, 4, 7, 8])
52+
3153
def test_session_log_uses_repo_hint(self):
3254
with tempfile.TemporaryDirectory() as tmp:
3355
repo = Path(tmp) / "repo"

‎src/commands/diff.rs‎

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ pub fn run(store: &Store, args: &DiffArgs) -> Result<()> {
1313
let commit = args.commit.as_deref().unwrap_or("HEAD");
1414
let entries = store.load_entries()?;
1515

16-
// Run git diff to get changed lines
16+
// Run a zero-context diff so changed-line parsing is not polluted by
17+
// surrounding context lines.
1718
let diff_output = run_git_diff(&store.repo_root, commit)?;
1819
let changed = parse_diff_hunks(&diff_output);
1920

@@ -69,7 +70,7 @@ pub fn run(store: &Store, args: &DiffArgs) -> Result<()> {
6970
total_changed,
7071
total_ai,
7172
if total_changed > 0 {
72-
(total_ai as f64 / total_changed as f64 * 100.0)
73+
total_ai as f64 / total_changed as f64 * 100.0
7374
} else {
7475
0.0
7576
}
@@ -79,52 +80,116 @@ pub fn run(store: &Store, args: &DiffArgs) -> Result<()> {
7980
}
8081

8182
fn run_git_diff(repo_root: &std::path::Path, commit: &str) -> Result<String> {
83+
let spec = diff_spec(commit);
8284
let output = std::process::Command::new("git")
83-
.args(["diff", commit])
85+
.args(["diff", "--unified=0", "--no-color", "--no-ext-diff", &spec])
8486
.current_dir(repo_root)
8587
.output()?;
88+
if !output.status.success() {
89+
let stderr = String::from_utf8_lossy(&output.stderr);
90+
anyhow::bail!("git diff failed: {}", stderr.trim());
91+
}
8692
Ok(String::from_utf8_lossy(&output.stdout).to_string())
8793
}
8894

95+
fn diff_spec(commit: &str) -> String {
96+
if commit.contains("..") {
97+
commit.to_string()
98+
} else {
99+
format!("{commit}^!")
100+
}
101+
}
102+
89103
fn parse_diff_hunks(diff: &str) -> std::collections::HashMap<String, Vec<u32>> {
90104
let mut result: std::collections::HashMap<String, Vec<u32>> = std::collections::HashMap::new();
91105
let mut current_file = String::new();
92-
let mut base_line: u32 = 0;
106+
let mut new_line: u32 = 0;
93107

94108
for line in diff.lines() {
95109
if line.starts_with("diff --git") {
96-
// Extract file from "diff --git a/path b/path"
97-
if let Some(path) = line.split_whitespace().last() {
98-
// Remove "b/" prefix
110+
current_file.clear();
111+
continue;
112+
}
113+
114+
if let Some(path) = line.strip_prefix("+++ ") {
115+
if path == "/dev/null" {
116+
current_file.clear();
117+
} else {
99118
current_file = path.trim_start_matches("b/").to_string();
100119
}
101-
} else if line.starts_with("@@") {
120+
continue;
121+
}
122+
123+
if line.starts_with("@@") {
102124
// Parse hunk header: @@ -start,count +start,count @@
103125
if let Some(end) = line.find(" @@") {
104126
let header = &line[4..end];
105127
if let Some(pos) = header.find("+") {
106128
let after_plus = &header[pos + 1..];
107129
if let Some(comma) = after_plus.find(',') {
108-
base_line = after_plus[..comma].parse().unwrap_or(1);
130+
new_line = after_plus[..comma].parse().unwrap_or(1);
109131
} else {
110-
base_line = after_plus.parse().unwrap_or(1);
132+
new_line = after_plus.parse().unwrap_or(1);
111133
}
112134
}
113135
}
114-
} else if (line.starts_with('+') || line.starts_with(' '))
115-
&& !line.starts_with("+++")
116-
&& !line.starts_with("index")
117-
{
118-
// Added or context line
136+
continue;
137+
}
138+
139+
if line.starts_with('+') && !line.starts_with("+++") {
119140
if !current_file.is_empty() {
120141
result
121142
.entry(current_file.clone())
122143
.or_default()
123-
.push(base_line);
144+
.push(new_line);
124145
}
125-
base_line += 1;
146+
new_line += 1;
147+
} else if line.starts_with(' ') {
148+
new_line += 1;
126149
}
127150
}
128151

152+
for lines in result.values_mut() {
153+
lines.sort_unstable();
154+
lines.dedup();
155+
}
156+
129157
result
130158
}
159+
160+
#[cfg(test)]
161+
mod tests {
162+
use super::*;
163+
164+
#[test]
165+
fn default_commit_diff_uses_commit_changes_not_worktree_delta() {
166+
assert_eq!(diff_spec("HEAD"), "HEAD^!");
167+
assert_eq!(diff_spec("abc123"), "abc123^!");
168+
}
169+
170+
#[test]
171+
fn explicit_range_is_preserved() {
172+
assert_eq!(diff_spec("main..HEAD"), "main..HEAD");
173+
assert_eq!(diff_spec("main...HEAD"), "main...HEAD");
174+
}
175+
176+
#[test]
177+
fn parses_only_new_side_changed_lines() {
178+
let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
179+
index 1111111..2222222 100644
180+
--- a/src/lib.rs
181+
+++ b/src/lib.rs
182+
@@ -10,3 +10,3 @@
183+
context
184+
-old
185+
+new
186+
context
187+
@@ -20,0 +21,2 @@
188+
+added one
189+
+added two
190+
"#;
191+
192+
let changed = parse_diff_hunks(diff);
193+
assert_eq!(changed.get("src/lib.rs"), Some(&vec![11, 21, 22]));
194+
}
195+
}

‎src/commands/install_ci.rs‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ on:
5454
permissions:
5555
contents: read
5656
checks: write
57+
pull-requests: write
5758
5859
jobs:
5960
policy:
@@ -67,6 +68,10 @@ jobs:
6768
run: |
6869
git fetch origin '+refs/agentdiff/*:refs/agentdiff/*' || true
6970
71+
- name: Check out PR head branch
72+
run: |
73+
git checkout -B "${{ github.head_ref }}" "${{ github.event.pull_request.head.sha }}"
74+
7075
- name: Install agentdiff
7176
run: |
7277
curl -fsSL https://raw.githubusercontent.com/codeprakhar25/agentdiff/main/install.sh | bash
@@ -75,6 +80,14 @@ jobs:
7580
- name: Check policy
7681
run: |
7782
agentdiff policy check --format github-annotations
83+
84+
- name: Post attribution comment
85+
if: always()
86+
env:
87+
GH_TOKEN: ${{ github.token }}
88+
run: |
89+
PR="${{ github.event.pull_request.number }}"
90+
agentdiff report --format markdown --post-pr-comment "$PR" || true
7891
"#;
7992

8093
pub fn run(repo_root: &Path, args: &InstallCiArgs) -> Result<()> {
@@ -86,18 +99,35 @@ pub fn run(repo_root: &Path, args: &InstallCiArgs) -> Result<()> {
8699
let consolidate_path = workflows_dir.join("agentdiff-consolidate.yml");
87100
let policy_path = workflows_dir.join("agentdiff-policy.yml");
88101

89-
write_workflow(&consolidate_path, CONSOLIDATE_WORKFLOW, args.force, "agentdiff-consolidate.yml")?;
90-
write_workflow(&policy_path, POLICY_WORKFLOW, args.force, "agentdiff-policy.yml")?;
102+
write_workflow(
103+
&consolidate_path,
104+
CONSOLIDATE_WORKFLOW,
105+
args.force,
106+
"agentdiff-consolidate.yml",
107+
)?;
108+
write_workflow(
109+
&policy_path,
110+
POLICY_WORKFLOW,
111+
args.force,
112+
"agentdiff-policy.yml",
113+
)?;
91114

92115
println!();
93116
println!(" {}", "install-ci complete".bold().green());
94117
println!();
95118
println!(" Next steps:");
96119
println!(" 1. Commit the workflow files:");
97-
println!(" git add .github/workflows/agentdiff-consolidate.yml .github/workflows/agentdiff-policy.yml");
120+
println!(
121+
" git add .github/workflows/agentdiff-consolidate.yml .github/workflows/agentdiff-policy.yml"
122+
);
98123
println!(" git commit -m 'ci: add agentdiff consolidation and policy workflows'");
99-
println!(" 2. Ensure each developer runs: {}", "agentdiff init".cyan());
100-
println!(" 3. On merge, traces auto-consolidate and an attribution comment is posted to the PR.");
124+
println!(
125+
" 2. Ensure each developer runs: {}",
126+
"agentdiff init".cyan()
127+
);
128+
println!(
129+
" 3. On merge, traces auto-consolidate and an attribution comment is posted to the PR."
130+
);
101131

102132
Ok(())
103133
}

0 commit comments

Comments
 (0)