Skip to content

Commit 6140cee

Browse files
[CI] PR change report: narrow agent to function-range identification only
The agent step on auto_diff.cpp (a 2540-LoC file with a 690-line diff) was hanging on claude-4.6-opus-high-thinking for 15+ minutes because the agent was doing both function identification AND line counting AND output formatting, with the slowest model variant available. Refactor: the agent now produces ONLY a JSONL stream of function ranges (name + HEAD/base line bounds), and only for functions whose body intersects a diff hunk. Everything else -- per-function totals, +/- attribution, and the report shape -- is computed deterministically from the diff and the HEAD/base files in `render_report.py`. To support this: - `build_inputs.py` now also dumps `head/<path>` (so `render_report.py` is self-contained), and writes `touched_ranges.txt` -- per-file HEAD and base hunk ranges -- as a hint so the agent only has to read code near touched regions. - `render_report.py` consumes phase-1 outputs + the agent's JSONL, computes total/added/removed per function (using the same code-line-set definition as the file-level totals), and emits the final report.txt. - The workflow agent step is rewritten with the narrow JSONL prompt, and the model is dropped from `claude-4.6-opus-high-thinking` to `claude-4.6-sonnet-thinking` (function identification is structural pattern-matching, not deep reasoning). - `touched_ranges.txt` and `function_ranges.jsonl` are uploaded with the existing artifacts for debuggability. Verified locally with synthetic JSONL on PR #621's auto_diff.cpp diff (NEW + modified functions both render correctly) and on the empty-JSONL case (file headers preserved, with a "no per-function attribution available" note so the report is still useful if the agent fails). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7fea1af commit 6140cee

3 files changed

Lines changed: 405 additions & 60 deletions

File tree

.github/workflows/pr_change_report.yml

Lines changed: 62 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -61,61 +61,72 @@ jobs:
6161
curl https://cursor.com/install -fsS | bash
6262
echo "$HOME/.cursor/bin" >> $GITHUB_PATH
6363
64-
- name: Generate per-function breakdown with Cursor agent
64+
- name: Identify changed function ranges with Cursor agent
6565
if: steps.inputs.outputs.skip != 'true'
6666
env:
6767
CURSOR_API_KEY: ${{ secrets.CURSOR_KEY_HUGH }}
6868
run: |
6969
agent -p "$(cat <<'PROMPT'
70-
You are producing a PR change report. Inputs are under /tmp/pr_change_report/ :
71-
- summary.json : list of {"path","language","total","added","removed"} for each
72-
changed source file. Numbers are NON-comment, NON-blank code
73-
lines (Python multi-line strings and docstrings also excluded),
74-
computed deterministically. Treat them as authoritative.
75-
- report_header.md : exact file-header lines you must use verbatim, e.g.
76-
foo.py 700 +63 -5
77-
blah.cpp 2000 +45 -20
78-
- file_list.txt : changed paths, one per line, in the order you must emit.
79-
- diffs/<path>.diff : per-file unified diff vs merge-base.
80-
- base/<path> : full base content of that file (missing if newly added).
81-
The HEAD content of each file is in the workspace (current checkout) at the same path.
82-
83-
Your task: for each path in file_list.txt, in order, emit:
84-
1. The exact file-header line from report_header.md, verbatim, on its own line.
85-
2. Indented (4 spaces) per-function lines, one per function/method that was added
86-
or modified by this PR. Format:
87-
<name>() NEW +<added> (function did not exist on base)
88-
<name>() <total> +<added> (only added code lines)
89-
<name>() <total> -<removed> (only removed code lines)
90-
<name>() <total> +<added> -<removed> (both)
91-
where:
92-
- <total> = current LoC of the function in HEAD (NON-comment, NON-blank;
93-
for Python, also exclude docstrings and multi-line-string-only
94-
continuation lines).
95-
- <added> = code lines this function gained.
96-
- <removed> = code lines this function lost.
97-
For top-level statements that are not inside a function/class, group them
98-
under the synthetic name `<module>()`.
99-
3. After the last function line for a file, a single blank line before the next
100-
file's header.
101-
102-
Constraints:
103-
- Use function names as they appear in source. C++ class methods are
104-
"ClassName::method". Free functions are just "func". Lambdas inside another
105-
function may be skipped or grouped under their enclosing function.
106-
- Only list functions whose +/- counts are non-zero. Do not include unchanged
107-
functions.
108-
- Do not invent functions that do not exist in HEAD or base.
109-
- Sums of per-function +/- counts SHOULD MATCH the file-level +/- counts in
110-
report_header.md. Small drift (1-2 lines) is acceptable for unusual hunks
111-
(e.g. straddling block-comment boundaries) but if drift exceeds 5 lines on
112-
either side, add ONE line below that file's section, indented 4 spaces:
113-
# note: per-function +/- differs from file totals by +N -M
114-
- Output ONLY the report. No preamble, no headings, no closing remarks, no
115-
code fences, no explanations.
70+
You are identifying which functions were touched by a PR. Line counts and +/-
71+
attribution are computed deterministically AFTER your output, so do NOT count
72+
lines yourself.
73+
74+
Inputs under /tmp/pr_change_report/ :
75+
- file_list.txt changed source paths (.py / .c / .cc / .cpp / .h / .hpp /
76+
.cu), one per line.
77+
- touched_ranges.txt per-file, the line-number ranges in HEAD and base that the
78+
diff hunks cover. This tells you exactly where to look --
79+
you should only need to inspect lines inside (or close to)
80+
these ranges, not the whole file.
81+
- diffs/<path>.diff per-file unified diff vs merge-base.
82+
- head/<path> full HEAD content of that file (also reachable from the
83+
workspace at the same path).
84+
- base/<path> full base content (missing if the file is newly added).
85+
86+
For each file in file_list.txt, identify every function or method whose body
87+
contains at least one ``+`` or ``-`` line in the diff. Emit ONE JSON object per
88+
such function on its own line, with these keys:
89+
path : the file path, exactly as it appears in file_list.txt.
90+
name : the function or method name. C++ class methods MUST be
91+
"ClassName::method"; free functions are just "func"; top-level statements
92+
that are not inside any function/class go under the synthetic name
93+
"<module>".
94+
head : [start_line, end_line] in HEAD, both 1-indexed and inclusive (signature
95+
line through closing brace / dedent), or null if the function was
96+
DELETED by this PR.
97+
base : [start_line, end_line] in the base version, or null if the function was
98+
NEWLY added by this PR.
99+
100+
Rules:
101+
- Only emit functions that overlap a hunk in touched_ranges.txt. Skip every
102+
other function in the file.
103+
- For functions modified by this PR, both head and base are filled (their line
104+
ranges may differ).
105+
- Anonymous lambdas inside another function are NOT separate functions; their
106+
touched lines belong to the enclosing function.
107+
- The end_line of a function is the line of the closing brace (C/C++) or the
108+
last indented body line (Python), inclusive.
109+
- Use the touched_ranges.txt hints to narrow your reading -- you should not need
110+
to read whole large files. Read only enough HEAD and base context to bracket
111+
each touched function with confidence.
112+
113+
Output: JSON Lines. ONE JSON object per line. Nothing else. NO code fences, NO
114+
preamble, NO closing remarks, NO commentary, NO blank lines between objects.
116115
PROMPT
117-
)" --model claude-4.6-opus-high-thinking --mode ask --output-format text --trust \
118-
> /tmp/pr_change_report/report.txt
116+
)" --model claude-4.6-sonnet-thinking --mode ask --output-format text --trust \
117+
> /tmp/pr_change_report/function_ranges.jsonl
118+
119+
echo "----- function_ranges.jsonl -----"
120+
cat /tmp/pr_change_report/function_ranges.jsonl
121+
echo "---------------------------------"
122+
123+
- name: Render report.txt deterministically
124+
if: steps.inputs.outputs.skip != 'true'
125+
run: |
126+
python .github/workflows/scripts_new/pr_change_report/render_report.py \
127+
--output-dir /tmp/pr_change_report \
128+
--function-ranges /tmp/pr_change_report/function_ranges.jsonl \
129+
--output /tmp/pr_change_report/report.txt
119130
120131
echo "----- report.txt -----"
121132
cat /tmp/pr_change_report/report.txt
@@ -158,6 +169,8 @@ jobs:
158169
/tmp/pr_change_report/report_comment.md
159170
/tmp/pr_change_report/summary.json
160171
/tmp/pr_change_report/report_header.md
172+
/tmp/pr_change_report/touched_ranges.txt
173+
/tmp/pr_change_report/function_ranges.jsonl
161174
162175
- name: Publish PR change report Check
163176
if: steps.inputs.outputs.skip != 'true' && github.event_name == 'pull_request'

.github/workflows/scripts_new/pr_change_report/build_inputs.py

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
``<output_dir>/report_header.md`` pre-formatted file-header lines (verbatim for the report)
99
``<output_dir>/report_comment.md`` compact PR-comment markdown (table + totals line)
1010
``<output_dir>/diffs/<path>.diff`` per-file unified diff vs. merge-base
11+
``<output_dir>/head/<path>`` HEAD content of the file (always present)
1112
``<output_dir>/base/<path>`` file content at merge-base (absent for newly added files)
1213
1314
``total`` is the number of code lines in the HEAD version of the file. ``added`` and ``removed`` are
@@ -203,15 +204,15 @@ def code_line_set(src: str, language: str) -> set[int]:
203204
HUNK_RE = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
204205

205206

206-
def diff_added_removed(diff_text: str, head_codes: set[int], base_codes: set[int]) -> tuple[int, int]:
207-
"""Count code-line additions and removals in a unified diff.
207+
def diff_changed_line_numbers(diff_text: str) -> tuple[list[int], list[int]]:
208+
"""Walk a unified diff and return ``(added_new_line_nos, removed_old_line_nos)``.
208209
209-
For each ``+`` line, the line number in the new file must be in ``head_codes``; for each ``-``
210-
line, the line number in the old file must be in ``base_codes``. This way block comments are
211-
excluded correctly because the code-line sets were computed with full block-comment stripping.
210+
Each list contains the 1-indexed line number that each ``+`` (or ``-``) line maps to in the
211+
new (or old) file -- one entry per diff line. Hunk headers, file headers and the
212+
``\\ No newline at end of file`` marker are skipped.
212213
"""
213-
added = 0
214-
removed = 0
214+
added: list[int] = []
215+
removed: list[int] = []
215216
new_no = 0
216217
old_no = 0
217218
in_hunk = False
@@ -230,12 +231,10 @@ def diff_added_removed(diff_text: str, head_codes: set[int], base_codes: set[int
230231
if not in_hunk:
231232
continue
232233
if line.startswith("+"):
233-
if new_no in head_codes:
234-
added += 1
234+
added.append(new_no)
235235
new_no += 1
236236
elif line.startswith("-"):
237-
if old_no in base_codes:
238-
removed += 1
237+
removed.append(old_no)
239238
old_no += 1
240239
elif line.startswith(" "):
241240
new_no += 1
@@ -245,6 +244,72 @@ def diff_added_removed(diff_text: str, head_codes: set[int], base_codes: set[int
245244
return added, removed
246245

247246

247+
def diff_added_removed(diff_text: str, head_codes: set[int], base_codes: set[int]) -> tuple[int, int]:
248+
"""Count code-line additions and removals in a unified diff.
249+
250+
A ``+`` line is counted if its new-file line number is in ``head_codes``; a ``-`` line is
251+
counted if its old-file line number is in ``base_codes``. This excludes block-comment
252+
content correctly because the code-line sets were computed with full comment stripping.
253+
"""
254+
added_lines, removed_lines = diff_changed_line_numbers(diff_text)
255+
added = sum(1 for ln in added_lines if ln in head_codes)
256+
removed = sum(1 for ln in removed_lines if ln in base_codes)
257+
return added, removed
258+
259+
260+
def diff_touched_ranges(diff_text: str) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]:
261+
"""Return the (head_ranges, base_ranges) covered by hunks of ``diff_text``.
262+
263+
Each range is ``(start_line, end_line)`` 1-indexed inclusive. The head ranges cover the
264+
new-file line numbers spanned by each hunk's ``+``/context lines; base ranges cover the
265+
old-file line numbers spanned by ``-``/context lines. Useful as a hint to the agent about
266+
where to look for changed functions without scanning the whole file.
267+
"""
268+
head_ranges: list[tuple[int, int]] = []
269+
base_ranges: list[tuple[int, int]] = []
270+
new_no = 0
271+
old_no = 0
272+
head_start = base_start = None
273+
head_last = base_last = None
274+
for line in diff_text.splitlines():
275+
if line.startswith("+++") or line.startswith("---"):
276+
continue
277+
if line.startswith("@@"):
278+
if head_start is not None:
279+
head_ranges.append((head_start, head_last))
280+
if base_start is not None:
281+
base_ranges.append((base_start, base_last))
282+
m = HUNK_RE.match(line)
283+
if not m:
284+
head_start = base_start = head_last = base_last = None
285+
continue
286+
old_no = int(m.group(1))
287+
new_no = int(m.group(3))
288+
head_start = new_no
289+
base_start = old_no
290+
head_last = new_no - 1
291+
base_last = old_no - 1
292+
continue
293+
if line.startswith("+"):
294+
head_last = new_no
295+
new_no += 1
296+
elif line.startswith("-"):
297+
base_last = old_no
298+
old_no += 1
299+
elif line.startswith(" "):
300+
head_last = new_no
301+
base_last = old_no
302+
new_no += 1
303+
old_no += 1
304+
elif line.startswith("\\"):
305+
continue
306+
if head_start is not None and head_last >= head_start:
307+
head_ranges.append((head_start, head_last))
308+
if base_start is not None and base_last >= base_start:
309+
base_ranges.append((base_start, base_last))
310+
return head_ranges, base_ranges
311+
312+
248313
@dataclass
249314
class FileSummary:
250315
path: str
@@ -306,6 +371,7 @@ def main() -> int:
306371

307372
output_dir = Path(args.output_dir)
308373
diffs_dir = output_dir / "diffs"
374+
head_dir = output_dir / "head"
309375
base_dir = output_dir / "base"
310376
output_dir.mkdir(parents=True, exist_ok=True)
311377

@@ -342,6 +408,7 @@ def main() -> int:
342408
added, removed = diff_added_removed(diff_text, head_codes, base_codes)
343409

344410
write_file(diffs_dir / f"{path}.diff", diff_text)
411+
write_file(head_dir / path, head_content)
345412
if base_content:
346413
write_file(base_dir / path, base_content)
347414

@@ -361,6 +428,15 @@ def main() -> int:
361428

362429
(output_dir / "report_comment.md").write_text(render_comment_markdown(summaries, args.commit_hash))
363430

431+
touched_lines = []
432+
for s in summaries:
433+
diff_text = (diffs_dir / f"{s.path}.diff").read_text()
434+
head_ranges, base_ranges = diff_touched_ranges(diff_text)
435+
head_str = ", ".join(f"{a}-{b}" for a, b in head_ranges) or "(none)"
436+
base_str = ", ".join(f"{a}-{b}" for a, b in base_ranges) or "(none)"
437+
touched_lines.append(f"{s.path}\n head hunks: {head_str}\n base hunks: {base_str}")
438+
(output_dir / "touched_ranges.txt").write_text("\n".join(touched_lines) + ("\n" if touched_lines else ""))
439+
364440
print(f"Wrote summaries for {len(summaries)} file(s) to {output_dir}")
365441
if summaries:
366442
print()

0 commit comments

Comments
 (0)