Skip to content

Commit 9977672

Browse files
authored
[CI] Lines changed report: collapse PR comment to a single linked totals line (#632)
1 parent 3c93df0 commit 9977672

4 files changed

Lines changed: 154 additions & 67 deletions

File tree

.github/workflows/pr_change_report.yml

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,27 +42,45 @@ jobs:
4242
--output-dir /tmp/pr_change_report \
4343
--commit-hash "$SHORT_SHA"
4444
45-
if [ ! -s /tmp/pr_change_report/file_list.txt ]; then
45+
# Primary skip gate: any source-file change at all (modifications, additions, OR
46+
# deletions). summary.json is the full set; file_list.txt is the agent-input subset
47+
# that excludes deletions, so gating on file_list.txt would silently drop
48+
# deletion-only PRs.
49+
NUM_FILES=$(jq 'length' /tmp/pr_change_report/summary.json)
50+
if [ "$NUM_FILES" -eq 0 ]; then
4651
echo "skip=true" >> "$GITHUB_OUTPUT"
4752
echo "No source files changed."
4853
else
4954
echo "skip=false" >> "$GITHUB_OUTPUT"
5055
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
51-
echo "Files in report:"
52-
cat /tmp/pr_change_report/file_list.txt
56+
echo "Files in summary.json: $NUM_FILES"
5357
echo
5458
echo "Pre-computed file headers:"
5559
cat /tmp/pr_change_report/report_header.md
60+
61+
# Secondary skip gate: per-function attribution requires at least one non-deleted
62+
# file (the agent reads HEAD content). For deletion-only PRs there is nothing for
63+
# the agent to do, so skip the Cursor CLI install and agent invocation.
64+
if [ -s /tmp/pr_change_report/file_list.txt ]; then
65+
echo "skip_agent=false" >> "$GITHUB_OUTPUT"
66+
echo
67+
echo "Files for agent attribution:"
68+
cat /tmp/pr_change_report/file_list.txt
69+
else
70+
echo "skip_agent=true" >> "$GITHUB_OUTPUT"
71+
echo
72+
echo "All changed source files are deletions; skipping per-function agent step."
73+
fi
5674
fi
5775
5876
- name: Install Cursor CLI
59-
if: steps.inputs.outputs.skip != 'true'
77+
if: steps.inputs.outputs.skip != 'true' && steps.inputs.outputs.skip_agent != 'true'
6078
run: |
6179
curl https://cursor.com/install -fsS | bash
6280
echo "$HOME/.cursor/bin" >> $GITHUB_PATH
6381
6482
- name: Identify changed function ranges with Cursor agent
65-
if: steps.inputs.outputs.skip != 'true'
83+
if: steps.inputs.outputs.skip != 'true' && steps.inputs.outputs.skip_agent != 'true'
6684
env:
6785
CURSOR_API_KEY: ${{ secrets.CURSOR_KEY_HUGH }}
6886
run: |
@@ -219,11 +237,15 @@ jobs:
219237
with:
220238
script: |
221239
const fs = require('fs');
222-
let body = fs.readFileSync('/tmp/pr_change_report/report_comment.md', 'utf8');
240+
const summary = JSON.parse(
241+
fs.readFileSync('/tmp/pr_change_report/summary.json', 'utf8'));
242+
const numFiles = summary.length;
243+
const totalAdded = summary.reduce((acc, s) => acc + s.added, 0);
244+
const totalRemoved = summary.reduce((acc, s) => acc + s.removed, 0);
245+
const text = `Total: ${numFiles} file(s) changed, ` +
246+
`+${totalAdded} -${totalRemoved} code lines.`;
223247
const url = process.env.REPORT_URL;
224-
if (url) {
225-
body += `\n\n[Full per-function report](${url})`;
226-
}
248+
const body = url ? `[${text}](${url})` : text;
227249
await github.rest.issues.createComment({
228250
owner: context.repo.owner,
229251
repo: context.repo.repo,

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

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@
33
44
For each source file changed in the PR (.py / .c / .cc / .cpp / .h / .hpp / .cu) this writes:
55
6-
``<output_dir>/summary.json`` list of ``{path, language, total, added, removed}``
7-
``<output_dir>/file_list.txt`` one path per line (same order as ``summary.json``)
6+
``<output_dir>/summary.json`` list of ``{path, language, total, added, removed, is_deleted}``
7+
``<output_dir>/file_list.txt`` one path per line; SAME order as ``summary.json`` but EXCLUDES
8+
deleted files (they have no HEAD content for the agent to attribute)
89
``<output_dir>/report_header.md`` pre-formatted file-header lines (verbatim for the report)
910
``<output_dir>/report_comment.md`` compact PR-comment markdown (table + totals line)
1011
``<output_dir>/diffs/<path>.diff`` per-file unified diff vs. merge-base
1112
``<output_dir>/head/<path>`` HEAD content of the file (always present)
1213
``<output_dir>/base/<path>`` file content at merge-base (absent for newly added files)
1314
14-
``total`` is the number of code lines in the HEAD version of the file. ``added`` and ``removed`` are
15-
the number of code lines added or removed by this PR (vs. merge-base). A "code line" excludes blank
16-
lines, lines whose only non-whitespace content is a comment, and (in Python) lines whose only token
17-
content is a string literal -- i.e. docstrings and continuation lines of multi-line strings. C/C++
18-
``/* ... */`` block comments are stripped before counting.
15+
``total`` is the number of code lines in the BASE (pre-PR, merge-base) version of the file, i.e.
16+
the size of the file before this PR's changes. For newly-added files this is 0. ``added`` and
17+
``removed`` are the number of code lines added or removed by this PR (vs. merge-base). A "code
18+
line" excludes blank lines, lines whose only non-whitespace content is a comment, and (in Python)
19+
lines whose only token content is a string literal (i.e. docstrings and continuation lines of
20+
multi-line strings). C/C++ ``/* ... */`` block comments are stripped before counting.
1921
2022
The agent consumes these inputs to produce the per-function breakdown.
2123
"""
@@ -317,6 +319,7 @@ class FileSummary:
317319
total: int
318320
added: int
319321
removed: int
322+
is_deleted: bool = False
320323

321324

322325
def write_file(path: Path, content: str) -> None:
@@ -344,7 +347,10 @@ def render_comment_markdown(summaries: list[FileSummary], commit: str) -> str:
344347
if not summaries:
345348
lines.append("No source files (.py, .c, .cc, .cpp, .h, .hpp, .cu) changed in this PR.")
346349
return "\n".join(lines) + "\n"
347-
lines.append("Code lines (excluding blank lines, comment-only lines, and Python multi-line strings).")
350+
lines.append(
351+
"LoC = code lines in the file BEFORE this PR (0 for newly-added files). "
352+
"Excludes blank lines, comment-only lines, and Python multi-line strings."
353+
)
348354
lines.append("")
349355
lines.append("| File | LoC | Added | Removed |")
350356
lines.append("|------|-----|-------|---------|")
@@ -388,8 +394,7 @@ def main() -> int:
388394
continue
389395
parts = line.split("\t")
390396
status = parts[0]
391-
if status.startswith("D"):
392-
continue
397+
is_deleted = status.startswith("D")
393398
# For renames / copies the new path is the last field; for plain modifications it is the only path.
394399
path = parts[-1]
395400
lang = language_for(path)
@@ -400,32 +405,54 @@ def main() -> int:
400405
if not diff_text.strip():
401406
continue
402407

403-
head_content = run_git(["show", f"{args.head_ref}:{path}"], check=False)
404-
base_content = run_git(["show", f"{merge_base}:{path}"], check=False)
405-
406-
head_codes = code_line_set(head_content, lang)
407-
base_codes = code_line_set(base_content, lang) if base_content else set()
408-
added, removed = diff_added_removed(diff_text, head_codes, base_codes)
408+
if is_deleted:
409+
head_content = ""
410+
base_content = run_git(["show", f"{merge_base}:{path}"], check=False)
411+
head_codes: set[int] = set()
412+
base_codes = code_line_set(base_content, lang) if base_content else set()
413+
added = 0
414+
removed = len(base_codes)
415+
else:
416+
head_content = run_git(["show", f"{args.head_ref}:{path}"], check=False)
417+
base_content = run_git(["show", f"{merge_base}:{path}"], check=False)
418+
head_codes = code_line_set(head_content, lang)
419+
base_codes = code_line_set(base_content, lang) if base_content else set()
420+
added, removed = diff_added_removed(diff_text, head_codes, base_codes)
409421

410422
write_file(diffs_dir / f"{path}.diff", diff_text)
411-
write_file(head_dir / path, head_content)
423+
if head_content:
424+
write_file(head_dir / path, head_content)
412425
if base_content:
413426
write_file(base_dir / path, base_content)
414427

415-
summaries.append(FileSummary(path=path, language=lang, total=len(head_codes), added=added, removed=removed))
428+
summaries.append(
429+
FileSummary(
430+
path=path, language=lang, total=len(base_codes), added=added, removed=removed, is_deleted=is_deleted
431+
)
432+
)
416433

417434
# Sort files by descending lines added, then descending lines removed, then path. This is the
418435
# order used for every downstream artifact (summary.json, file_list.txt, report_header.md,
419436
# report_comment.md) so that the report and PR comment surface the largest-impact files first.
420437
summaries.sort(key=lambda s: (-s.added, -s.removed, s.path))
421438

422439
summary_dicts = [
423-
{"path": s.path, "language": s.language, "total": s.total, "added": s.added, "removed": s.removed}
440+
{
441+
"path": s.path,
442+
"language": s.language,
443+
"total": s.total,
444+
"added": s.added,
445+
"removed": s.removed,
446+
"is_deleted": s.is_deleted,
447+
}
424448
for s in summaries
425449
]
426450
(output_dir / "summary.json").write_text(json.dumps(summary_dicts, indent=2) + "\n")
427451

428-
file_list = "\n".join(s.path for s in summaries)
452+
# file_list.txt and touched_ranges.txt feed the agent. Deleted files have no HEAD content
453+
# and no per-function attribution to compute, so they are excluded from both. They still
454+
# appear in summary.json / report_comment.md / report.txt with correct totals.
455+
file_list = "\n".join(s.path for s in summaries if not s.is_deleted)
429456
(output_dir / "file_list.txt").write_text(file_list + ("\n" if file_list else ""))
430457

431458
headers = "\n".join(format_header(s) for s in summaries)
@@ -435,6 +462,8 @@ def main() -> int:
435462

436463
touched_lines = []
437464
for s in summaries:
465+
if s.is_deleted:
466+
continue
438467
diff_text = (diffs_dir / f"{s.path}.diff").read_text()
439468
head_ranges, base_ranges = diff_touched_ranges(diff_text)
440469
head_str = ", ".join(f"{a}-{b}" for a, b in head_ranges) or "(none)"

.github/workflows/scripts_new/pr_change_report/render_report.py

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,22 @@
1616
1717
<path> <total> +<added> -<removed>
1818
New:
19-
<func>() NEW +<added>
19+
<func>() <total> +<added>
2020
...
2121
Existing:
22-
<func>() <total> +<added> -<removed>
22+
<func>() <total> +<added> -<removed>
23+
...
24+
Deleted:
25+
<func>() <total> -<removed>
2326
...
2427
25-
Within each file, functions are split into a ``New:`` group (added by this PR) and an
26-
``Existing:`` group (modified or deleted by this PR), and within each group sorted by added
27-
lines descending, then removed lines descending. Function-name and numeric columns are
28-
padded with spaces so the columns line up within a file.
28+
The ``<total>`` column at every level is the BASE (pre-PR) code-line count: file size before
29+
the PR (or 0 for newly-added files); function body size before the PR (or 0 for new functions;
30+
the original body size for deleted functions). Within each file, functions are split into a
31+
``New:`` group (added by this PR), an ``Existing:`` group (modified by this PR), and a
32+
``Deleted:`` group (removed by this PR), and within each group sorted by added lines descending,
33+
then removed lines descending. Function-name and numeric columns are padded with spaces so the
34+
columns line up within a file.
2935
"""
3036

3137
from __future__ import annotations
@@ -63,6 +69,7 @@ class _FileBookkeeping:
6369
base_codes: set[int]
6470
added_line_nos: list[int]
6571
removed_line_nos: list[int]
72+
is_deleted: bool = False
6673

6774

6875
def _load_function_entries(jsonl_path: Path) -> dict[str, list[_FunctionEntry]]:
@@ -143,26 +150,29 @@ def _build_file_bookkeeping(summary: dict, output_dir: Path) -> _FileBookkeeping
143150
base_codes=base_codes,
144151
added_line_nos=added_line_nos,
145152
removed_line_nos=removed_line_nos,
153+
is_deleted=bool(summary.get("is_deleted", False)),
146154
)
147155

148156

149157
def _attribute_function(entry: _FunctionEntry, book: _FileBookkeeping) -> tuple[int, int, int, bool, bool]:
150158
"""Return ``(total, added, removed, is_new, is_deleted)`` for a single function entry.
151159
152-
``total`` is the number of code lines in the function's HEAD body. ``added`` / ``removed``
153-
are the number of ``+`` / ``-`` diff lines that fall inside the function's HEAD / base range
154-
AND are themselves code lines (excluding comments / blank lines / Python multi-line strings).
160+
``total`` is the number of code lines in the function's BASE (pre-PR) body, i.e. how big
161+
this function was before this PR. For NEW functions (no base body) this is 0. ``added`` /
162+
``removed`` are the number of ``+`` / ``-`` diff lines that fall inside the function's
163+
HEAD / base range AND are themselves code lines (excluding comments / blank lines / Python
164+
multi-line strings).
155165
"""
156166
total = 0
157167
added = 0
158168
removed = 0
159-
if entry.head_range is not None:
160-
a, b = entry.head_range
161-
total = sum(1 for ln in book.head_codes if a <= ln <= b)
162-
added = sum(1 for ln in book.added_line_nos if a <= ln <= b and ln in book.head_codes)
163169
if entry.base_range is not None:
164170
c, d = entry.base_range
171+
total = sum(1 for ln in book.base_codes if c <= ln <= d)
165172
removed = sum(1 for ln in book.removed_line_nos if c <= ln <= d and ln in book.base_codes)
173+
if entry.head_range is not None:
174+
a, b = entry.head_range
175+
added = sum(1 for ln in book.added_line_nos if a <= ln <= b and ln in book.head_codes)
166176
is_new = entry.head_range is not None and entry.base_range is None
167177
is_deleted = entry.head_range is None and entry.base_range is not None
168178
return total, added, removed, is_new, is_deleted
@@ -223,20 +233,16 @@ def _impact_sort_key(a: _AttributedEntry) -> tuple[int, int, str]:
223233

224234

225235
# Column widths chosen to fit the longest expected token in each column without crowding adjacent
226-
# columns. ``DELETED`` is 7 chars (the longest total token); five-digit ``+``/``-`` counts are
227-
# the longest expected numeric tokens.
228-
_TOTAL_WIDTH = 8
236+
# columns. Five-digit ``+``/``-`` counts are the longest expected numeric tokens; the total
237+
# column holds a base (pre-PR) line count so it never exceeds a few digits in practice.
238+
_TOTAL_WIDTH = 6
229239
_ADDED_WIDTH = 7
230240
_REMOVED_WIDTH = 7
231241
_FUNC_INDENT = " "
232242
_GROUP_INDENT = " "
233243

234244

235245
def _format_total(a: _AttributedEntry) -> str:
236-
if a.is_deleted:
237-
return "DELETED"
238-
if a.is_new:
239-
return "NEW"
240246
return str(a.total)
241247

242248

@@ -269,17 +275,21 @@ def _emit_file_section(book: _FileBookkeeping, attributed: list[_AttributedEntry
269275
appended by the caller, not here.
270276
"""
271277
lines: list[str] = [format_header(_HeaderProxy(book))]
278+
if book.is_deleted:
279+
lines.append(f"{_GROUP_INDENT}# entire file deleted (per-function breakdown skipped)")
280+
return lines, 0, 0
272281
if not attributed:
273282
if book.added or book.removed:
274283
lines.append(f"{_GROUP_INDENT}# note: no per-function attribution available")
275284
return lines, 0, 0
276285

277286
new_group = sorted([a for a in attributed if a.is_new], key=_impact_sort_key)
278-
existing_group = sorted([a for a in attributed if not a.is_new], key=_impact_sort_key)
287+
deleted_group = sorted([a for a in attributed if a.is_deleted], key=_impact_sort_key)
288+
existing_group = sorted([a for a in attributed if not a.is_new and not a.is_deleted], key=_impact_sort_key)
279289

280-
# Pad function names to the longest name across BOTH groups so the New: and Existing:
281-
# subsections line up with each other.
282-
all_names = [_function_label(a.entry.name) for a in new_group + existing_group]
290+
# Pad function names to the longest name across ALL groups so the New: / Existing: /
291+
# Deleted: subsections line up with each other.
292+
all_names = [_function_label(a.entry.name) for a in new_group + existing_group + deleted_group]
283293
name_width = max((len(n) for n in all_names), default=0)
284294

285295
if new_group:
@@ -290,6 +300,10 @@ def _emit_file_section(book: _FileBookkeeping, attributed: list[_AttributedEntry
290300
lines.append(f"{_GROUP_INDENT}Existing:")
291301
for a in existing_group:
292302
lines.append(_format_aligned_function(a, name_width))
303+
if deleted_group:
304+
lines.append(f"{_GROUP_INDENT}Deleted:")
305+
for a in deleted_group:
306+
lines.append(_format_aligned_function(a, name_width))
293307

294308
per_added = sum(a.added for a in attributed)
295309
per_removed = sum(a.removed for a in attributed)
@@ -303,6 +317,18 @@ def _emit_file_section(book: _FileBookkeeping, attributed: list[_AttributedEntry
303317
return lines, per_added, per_removed
304318

305319

320+
_FOOTER = (
321+
"Notes:\n"
322+
" * The number columns (without a + or - sign) are code-line counts in the "
323+
"BASE (pre-PR) version: file size before this PR (0 for newly-added files), "
324+
"function body size before this PR (0 for new functions; original body size "
325+
"for deleted functions).\n"
326+
" * +<n> / -<n> are code lines added / removed by this PR.\n"
327+
" * Code lines exclude blank lines, comment-only lines, and Python "
328+
"multi-line strings."
329+
)
330+
331+
306332
def render(summary_json: Path, jsonl_path: Path, output_dir: Path) -> str:
307333
summaries: list[dict] = json.loads(summary_json.read_text())
308334
by_path = _load_function_entries(jsonl_path)
@@ -316,6 +342,9 @@ def render(summary_json: Path, jsonl_path: Path, output_dir: Path) -> str:
316342
out_lines.append("")
317343
while out_lines and out_lines[-1] == "":
318344
out_lines.pop()
345+
if out_lines:
346+
out_lines.append("")
347+
out_lines.append(_FOOTER)
319348
return "\n".join(out_lines) + ("\n" if out_lines else "")
320349

321350

0 commit comments

Comments
 (0)