Skip to content

Commit 7aed79b

Browse files
authored
feat(gl-mr): surface per-file name-status by default (#332) (#333)
* feat(gl-mr): surface per-file name-status by default (#332) Add a `## Files (N)` block to the gl-mr dashboard: one A/D/R/M line per changed path, from the paginated merge_requests/:iid/diffs endpoint (change type via GitLab's new_file/deleted_file/renamed_file flags, no git shellout). "What got removed?" is the high-signal MR-review question; gl-mr previously gave only a file count, forcing a separate `git diff --name-status` round-trip. Capped at 50 files with a `… +N more` marker; gl-mr:N:full uncaps (paginates to 500). Any API/parse failure silently omits the block. 9 new tests (33 pass). Docs + CHANGELOG updated. Closes #332 Co-Authored-By: Max <noreply> * fix(gl-mr): rename old→new path, correct overflow count, testable display Review of #333 surfaced three issues: - Renames showed only new_path (R new.py) — now "R old.py → new.py" so the source path isn't lost (the one case a single path is ambiguous). - The "+N more" overflow undercounted: changes_count is always a *string* ("18"/"1000+"), so the isinstance(int) guard always fell through to the fetched-page count, undercounting on >100-file MRs in default mode. Added _coerce_count (leading-digits parse) so the total is authoritative. - Extracted the display math into _render_name_status so it's unit-testable; main() now just prints its lines. Adds 7 tests (rename formatting, _coerce_count, overflow math, full-mode cap, fallback). 40 gl-mr tests pass; full suite 3438 pass. Co-Authored-By: Max <noreply>
1 parent fc980fc commit 7aed79b

6 files changed

Lines changed: 254 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **`gl-mr` now lists the per-file name-status by default** — the MR dashboard prints a `## Files (N)` block with one `A`/`D`/`R`/`M` line per changed path, sourced from the paginated `merge_requests/:iid/diffs` endpoint (so the change type comes straight from GitLab's `new_file`/`deleted_file`/`renamed_file` flags, no git shellout). "What got removed?" is the high-signal question when reviewing an MR, and previously `gl-mr` gave only a file *count* — forcing a separate `git diff --name-status master...branch` round-trip, exactly the borrowed round-trip the variants exist to kill (the concrete trigger was auditing a deleted-migration concern on a real MR). The list is capped at 50 files with a `… +N more` marker so large MRs don't blow context; `gl-mr:N:full` uncaps it (paginating up to 500 files) alongside the existing comment uncap. Any API/parse failure silently omits the block. Closes [#332](https://github.com/Digital-Process-Tools/claude-supertool/issues/332).
1213
- **`git-status` gained a `:full` mode**`git-status:full` (alias `:porcelain`) uncaps every list in the dashboard (staged/unstaged/untracked files, other branches, stashes), printing the complete untruncated set instead of the default `... (N more)` markers. The default view stays capped (20 staged/unstaged, 10 untracked/branches, 5 stashes) — a cheap overview that answers ahead/behind + dirty + MR. The bug wasn't the truncation (correct for the common case) but the lack of an escape hatch: driving precise staging — e.g. excluding a few pre-existing untracked items from a large commit — needs the full machine-readable list, which previously forced a drop back to raw `git status --porcelain`. Closes [#330](https://github.com/Digital-Process-Tools/claude-supertool/issues/330).
1314
- **`gl-job` / `gh-job` gained a `:fail` suffix**`gl-job:ID:fail` (alias `:errors`) prints only the matched failure blocks with no tail, the tight triage view. It applies the same per-job pattern table as the default mode (rector → diff lines, phpstan → 🪪/type markers, unit → `FAILURES!`/`Failed asserting`), so a red job shows just its actionable failures instead of the default's blocks-plus-80-line-tail. This names the discoverable front door for a behavior that previously only existed as the undocumented `:errors` mode on `gl-job` (and was entirely absent on `gh-job`); `:errors` stays as a back-compat alias. The default (no suffix), `:grep:PATTERN`, and `:raw` are unchanged. Closes [#326](https://github.com/Digital-Process-Tools/claude-supertool/issues/326).
1415
- **`grep` gained a `:no-auto-read` flag**`grep:PATTERN:PATH:no-auto-read` suppresses the single-small-file auto-read so only the matching line(s) are emitted, mirroring `glob`'s existing flag. Default behavior (auto-read a concrete file < 20KB on a match) is unchanged. The flag is order-independent with `:count` and any `LIMIT`/`CONTEXT` args. Avoids silently dumping 10-18KB of unrequested file content when the caller only wants the hit. Closes [#320](https://github.com/Digital-Process-Tools/claude-supertool/issues/320).

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ supertool 'read:src/Module.py' 'read:src/Auth.py' 'grep:TODO:src/:20' 'map:src/'
3131
**Drill in 2026.** supertool gives the agent variants that pack the *next question* into the *current call*:
3232

3333
- **`git-status`** — branch + tracking + ahead/behind + dirty files + open MR/PR + suggested next step. One call, decision ready.
34-
- **`gl-mr:NUMBER`** / **`gh-pr:NUMBER`** — full MR/PR dashboard: branch, pipeline, reviewer, approval, diff stat, comments. Replaces 4-5 `glab`/`gh` calls.
34+
- **`gl-mr:NUMBER`** / **`gh-pr:NUMBER`** — full MR/PR dashboard: branch, pipeline, reviewer, approval, diff stat, per-file name-status (A/D/R/M) list, comments. Replaces 4-5 `glab`/`gh` calls.
3535
- **`gl-mrs`** — MR triage board: your open MRs + per-MR pipeline status + which already have a `watch` poller running + an actionable footer. Pairs with `watch` to auto-watch every failing MR.
3636
- **`claude-log-summary:UUID`** — model, duration, tool calls, tokens, cache hit %, errors-by-tool. Audit your own runs.
3737

docs/presets/gitlab.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ GitLab ops via the `glab` CLI. Replaces the 3-5 separate `glab` calls needed to
1111
| Op | Syntax | What it returns |
1212
|----|--------|-----------------|
1313
| `gl-issue` | `gl-issue:NUMBER[:full]` | Issue metadata, description, comments (truncated by default), related MRs. `:full` disables truncation |
14-
| `gl-mr` | `gl-mr:NUMBER_OR_BRANCH[:status]` | MR dashboard: branch, pipeline, reviewer/approval state, linked issue, diff stat, comments. `:status` returns slim merge-state only |
14+
| `gl-mr` | `gl-mr:NUMBER_OR_BRANCH[:status\|:full]` | MR dashboard: branch, pipeline, reviewer/approval state, linked issue, diff stat, per-file name-status (`A`/`D`/`R`/`M`) list, comments. The file list is the high-signal "what got removed?" scan; capped at 50 files by default with a `… +N more` marker. `:status` returns slim merge-state only; `:full` uncaps the file list (paginating up to 500) and the comments |
1515
| `gl-mrs` | `gl-mrs[:filters,flags]` | MR triage board, sorted failing-first then stalest. Per MR (enriched in parallel): pipeline status (a failure shows the failed **job name** = the failure class), approval state, age, diff size, watch-state cross-reference, and `conflict`/`draft`/`threads` flags — plus an actionable footer. Filters (comma-sep): `author`/`reviewer`/`assignee`/`label`/`milestone`/`state`/`per`. Flags: `nopipe` (skip enrichment), `iids` (bare id list), `failed` (only failing) |
1616
| `gl-pipeline` | `gl-pipeline:NUMBER[:active\|:failed]` | Pipeline job list grouped by stage with pass/fail status and failed job IDs. The default board collapses the `manual`/`created`/`skipped` bulk to a one-line count so the running/done/failed jobs aren't buried. `:active` shows only running/pending jobs ("what's still going"); `:failed` shows only failed jobs plus their job IDs/URLs ("what broke") |
1717
| `gl-job` | `gl-job:NUMBER[:raw[:START[:END]]\|:grep:PATTERN]` | Job failure detail: MR context + error pattern search + log tail. `:raw` dumps the full trace; `:raw:START:END` slices lines (1-indexed, inclusive); `:grep:PATTERN` runs an ad-hoc regex over the trace (literal fallback on bad regex, ±context, names the pattern + tail on no-match — never silent-empty) |

presets/gitlab.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
"gl-mr": {
1212
"cmd": "{python} {path}gitlab/mr.py {args}",
1313
"timeout": 20,
14-
"description": "MR review: branch, pipeline, approval, linked issue, diff stat, comments. :status = slim merge-state only",
15-
"syntax": "gl-mr:NUMBER_OR_BRANCH[:status]"
14+
"description": "MR review: branch, pipeline, approval, linked issue, diff stat, per-file name-status (A/D/R/M) list, comments. :status = slim merge-state only. :full uncaps the file list + comments",
15+
"syntax": "gl-mr:NUMBER_OR_BRANCH[:status|:full]"
1616
},
1717
"gl-mrs": {
1818
"cmd": "{python} {path}gitlab/mrs.py {args}",

presets/gitlab/mr.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
COMMENT_MAX = 500
1616
COMMENT_TOTAL_MAX = 2000
1717
TAIL_COMMENTS = 2
18+
NAMESTATUS_DISPLAY_MAX = 50
19+
NAMESTATUS_FETCH_CAP = 500
1820

1921

2022
def _relative_age(iso: str) -> str:
@@ -241,6 +243,95 @@ def _budgeted_comments(notes: list, budget: int, tail: int) -> tuple[list[str],
241243
return head_kept + (["__GAP__"] if hidden else []) + tail_slice, len(hidden), hidden_bytes
242244

243245

246+
def _name_status_flag(f: dict) -> str:
247+
"""Map a GitLab diff entry to a one-char change type (A/D/R/M)."""
248+
if f.get("new_file"):
249+
return "A"
250+
if f.get("deleted_file"):
251+
return "D"
252+
if f.get("renamed_file"):
253+
return "R"
254+
return "M"
255+
256+
257+
def _get_name_status(iid: str | int, fetch_all: bool) -> list[tuple[str, str]]:
258+
"""Return per-file (flag, path) for an MR via the paginated diffs endpoint.
259+
260+
Default fetches only the first page (100 files) — enough for the display
261+
cap. With fetch_all (gl-mr:N:full) it paginates up to NAMESTATUS_FETCH_CAP
262+
files. Returns [] on any API/parse failure so the caller silently omits
263+
the block rather than erroring.
264+
"""
265+
entries: list[tuple[str, str]] = []
266+
page = 1
267+
while True:
268+
try:
269+
r = _glab_api(
270+
f"projects/:id/merge_requests/{iid}/diffs?per_page=100&page={page}"
271+
)
272+
except (subprocess.TimeoutExpired, FileNotFoundError):
273+
break
274+
if r.returncode != 0:
275+
break
276+
try:
277+
diffs = json.loads(r.stdout)
278+
except json.JSONDecodeError:
279+
break
280+
if not isinstance(diffs, list) or not diffs:
281+
break
282+
for f in diffs:
283+
flag = _name_status_flag(f)
284+
new_path = f.get("new_path") or ""
285+
old_path = f.get("old_path") or ""
286+
if flag == "R" and old_path and new_path and old_path != new_path:
287+
path = f"{old_path}{new_path}"
288+
else:
289+
path = new_path or old_path or "?"
290+
entries.append((flag, path))
291+
if not fetch_all or len(diffs) < 100 or len(entries) >= NAMESTATUS_FETCH_CAP:
292+
break
293+
page += 1
294+
return entries
295+
296+
297+
def _coerce_count(changes: object) -> int | None:
298+
"""Return the leading integer of GitLab's changes_count.
299+
300+
changes_count comes back as a string — "18" on normal MRs, "1000+" when
301+
capped. Returns the leading int (1000 for "1000+"), or None when there are
302+
no leading digits, so callers can fall back to the fetched-entry count.
303+
"""
304+
m = re.match(r"\d+", str(changes))
305+
return int(m.group()) if m else None
306+
307+
308+
def _render_name_status(
309+
entries: list[tuple[str, str]], changes: object, full: bool, iid: str | int
310+
) -> list[str]:
311+
"""Build the '## Files' block lines from name-status entries.
312+
313+
Returns [] when there are no entries (caller omits the block). The total
314+
file count drives the "+N more" overflow line: it comes from changes_count
315+
(authoritative, survives the display cap and single-page fetch) and falls
316+
back to the fetched count when changes_count is missing or smaller.
317+
"""
318+
if not entries:
319+
return []
320+
shown = entries if full else entries[:NAMESTATUS_DISPLAY_MAX]
321+
total = _coerce_count(changes)
322+
if total is None or total < len(entries):
323+
total = len(entries)
324+
lines = [f"\n## Files ({changes})"]
325+
lines.extend(f" {flag} {path}" for flag, path in shown)
326+
hidden = total - len(shown)
327+
if hidden > 0:
328+
if full:
329+
lines.append(f" … +{hidden} more (output capped at {NAMESTATUS_FETCH_CAP} files)")
330+
else:
331+
lines.append(f" … +{hidden} more (use gl-mr:{iid}:full)")
332+
return lines
333+
334+
244335
def main() -> int:
245336
if len(sys.argv) < 2:
246337
print("ERROR: usage: mr.py NUMBER [status|full]")
@@ -500,6 +591,12 @@ def _pipe_meta(pipeline: dict) -> str:
500591
# glab mr view omits diff_stats on large MRs (typically 1000+ files)
501592
print(f"Changes: {changes} files (line counts unavailable on large MRs)")
502593

594+
# File-level name-status — the deletion/addition list is the high-signal
595+
# scan when reviewing an MR. Default-capped; gl-mr:N:full uncaps.
596+
if changes:
597+
for line in _render_name_status(_get_name_status(iid, full), changes, full, iid):
598+
print(line)
599+
503600
# Conflicts
504601
conflict_files: list[str] = []
505602
if has_conflicts:

tests/test_gitlab_mr.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,155 @@ def test_budgeted_comments_hidden_bytes_counts_utf8() -> None:
427427
_, hidden_count, hidden_bytes = mr._budgeted_comments(notes, budget=2000, tail=2)
428428
assert hidden_count > 0
429429
assert hidden_bytes == hidden_count * rendered_one_bytes
430+
431+
432+
# ---------------------------------------------------------------------------
433+
# _name_status_flag / _get_name_status
434+
# ---------------------------------------------------------------------------
435+
436+
def test_name_status_flag_mapping() -> None:
437+
assert mr._name_status_flag({"new_file": True}) == "A"
438+
assert mr._name_status_flag({"deleted_file": True}) == "D"
439+
assert mr._name_status_flag({"renamed_file": True}) == "R"
440+
assert mr._name_status_flag({}) == "M"
441+
442+
443+
def _api_json(payload: Any, returncode: int = 0) -> Any:
444+
import json as _json
445+
return subprocess.CompletedProcess(
446+
args=["glab"], returncode=returncode, stdout=_json.dumps(payload), stderr=""
447+
)
448+
449+
450+
def test_get_name_status_parses_flags_and_paths(monkeypatch) -> None:
451+
diffs = [
452+
{"new_file": True, "new_path": "a.py", "old_path": "a.py"},
453+
{"deleted_file": True, "new_path": "b.py", "old_path": "b.py"},
454+
{"renamed_file": True, "new_path": "d.py", "old_path": "c.py"},
455+
{"new_path": "e.py", "old_path": "e.py"},
456+
]
457+
monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json(diffs))
458+
assert mr._get_name_status(42, fetch_all=False) == [
459+
("A", "a.py"), ("D", "b.py"), ("R", "c.py → d.py"), ("M", "e.py"),
460+
]
461+
462+
463+
def test_get_name_status_rename_without_path_change_shows_single(monkeypatch) -> None:
464+
"""A renamed_file flag with identical paths (mode-only change) shows one path."""
465+
diffs = [{"renamed_file": True, "new_path": "x.py", "old_path": "x.py"}]
466+
monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json(diffs))
467+
assert mr._get_name_status(1, fetch_all=False) == [("R", "x.py")]
468+
469+
470+
def test_get_name_status_deleted_uses_old_path_when_new_missing(monkeypatch) -> None:
471+
diffs = [{"deleted_file": True, "new_path": "", "old_path": "gone.py"}]
472+
monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json(diffs))
473+
assert mr._get_name_status(1, fetch_all=False) == [("D", "gone.py")]
474+
475+
476+
def test_get_name_status_api_failure_returns_empty(monkeypatch) -> None:
477+
monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json([], returncode=1))
478+
assert mr._get_name_status(1, fetch_all=False) == []
479+
480+
481+
def test_get_name_status_bad_json_returns_empty(monkeypatch) -> None:
482+
bad = subprocess.CompletedProcess(args=["glab"], returncode=0, stdout="not json", stderr="")
483+
monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: bad)
484+
assert mr._get_name_status(1, fetch_all=False) == []
485+
486+
487+
def test_get_name_status_timeout_returns_empty(monkeypatch) -> None:
488+
def boom(*a: Any, **kw: Any) -> Any:
489+
raise subprocess.TimeoutExpired(cmd="glab", timeout=10)
490+
monkeypatch.setattr(mr, "_glab_api", boom)
491+
assert mr._get_name_status(1, fetch_all=False) == []
492+
493+
494+
def test_get_name_status_single_page_when_not_fetch_all(monkeypatch) -> None:
495+
"""A full page (100) must NOT trigger a second fetch unless fetch_all."""
496+
calls = []
497+
full_page = [{"new_path": f"f{i}.py", "old_path": f"f{i}.py"} for i in range(100)]
498+
499+
def fake(endpoint: str, *a: Any, **kw: Any) -> Any:
500+
calls.append(endpoint)
501+
return _api_json(full_page)
502+
503+
monkeypatch.setattr(mr, "_glab_api", fake)
504+
entries = mr._get_name_status(1, fetch_all=False)
505+
assert len(entries) == 100
506+
assert len(calls) == 1
507+
508+
509+
def test_get_name_status_paginates_when_fetch_all(monkeypatch) -> None:
510+
"""fetch_all walks pages until a short page signals the end."""
511+
page1 = [{"new_path": f"p1_{i}.py", "old_path": f"p1_{i}.py"} for i in range(100)]
512+
page2 = [{"new_path": "p2_0.py", "old_path": "p2_0.py"}] # short page -> stop
513+
514+
def fake(endpoint: str, *a: Any, **kw: Any) -> Any:
515+
return _api_json(page1 if "&page=1" in endpoint else page2)
516+
517+
monkeypatch.setattr(mr, "_glab_api", fake)
518+
entries = mr._get_name_status(1, fetch_all=True)
519+
assert len(entries) == 101
520+
assert entries[-1] == ("M", "p2_0.py")
521+
522+
523+
def test_get_name_status_respects_fetch_cap(monkeypatch) -> None:
524+
"""fetch_all stops once NAMESTATUS_FETCH_CAP files are collected."""
525+
full_page = [{"new_path": f"f{i}.py", "old_path": f"f{i}.py"} for i in range(100)]
526+
monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json(full_page))
527+
entries = mr._get_name_status(1, fetch_all=True)
528+
assert len(entries) == mr.NAMESTATUS_FETCH_CAP
529+
530+
531+
# ---------------------------------------------------------------------------
532+
# _coerce_count / _render_name_status (display-block math)
533+
# ---------------------------------------------------------------------------
534+
535+
def test_coerce_count_handles_string_and_capped() -> None:
536+
assert mr._coerce_count("18") == 18
537+
assert mr._coerce_count("1000+") == 1000 # GitLab caps large MRs as "N+"
538+
assert mr._coerce_count(42) == 42
539+
assert mr._coerce_count("") is None
540+
assert mr._coerce_count(None) is None
541+
542+
543+
def test_render_name_status_empty_entries_returns_nothing() -> None:
544+
assert mr._render_name_status([], "0", full=False, iid=1) == []
545+
546+
547+
def test_render_name_status_under_cap_no_overflow() -> None:
548+
entries = [("A", "a.py"), ("D", "b.py")]
549+
lines = mr._render_name_status(entries, "2", full=False, iid=7)
550+
assert lines == ["\n## Files (2)", " A a.py", " D b.py"]
551+
assert not any("more" in ln for ln in lines)
552+
553+
554+
def test_render_name_status_overflow_uses_changes_count_not_fetched() -> None:
555+
"""The +N more count must come from changes_count, not the fetched page.
556+
557+
Regression: changes_count is a *string* ("200"), so an isinstance(int)
558+
guard always fell through to the fetched-entry count — undercounting the
559+
overflow on >100-file MRs. Here 100 fetched, 50 shown, true total 200.
560+
"""
561+
entries = [("M", f"f{i}.py") for i in range(100)]
562+
lines = mr._render_name_status(entries, "200", full=False, iid=9)
563+
assert lines[0] == "\n## Files (200)"
564+
assert len([ln for ln in lines if ln.startswith(" M")]) == mr.NAMESTATUS_DISPLAY_MAX
565+
assert lines[-1] == f" … +{200 - mr.NAMESTATUS_DISPLAY_MAX} more (use gl-mr:9:full)"
566+
567+
568+
def test_render_name_status_falls_back_to_len_when_count_smaller() -> None:
569+
"""If changes_count is missing/smaller than fetched, use the fetched count."""
570+
entries = [("A", f"a{i}.py") for i in range(60)]
571+
lines = mr._render_name_status(entries, "", full=False, iid=1)
572+
assert lines[-1] == f" … +{60 - mr.NAMESTATUS_DISPLAY_MAX} more (use gl-mr:1:full)"
573+
574+
575+
def test_render_name_status_full_mode_cap_message() -> None:
576+
"""In full mode an overflow points at the fetch cap, not :full again."""
577+
entries = [("M", f"f{i}.py") for i in range(mr.NAMESTATUS_FETCH_CAP)]
578+
lines = mr._render_name_status(entries, "1200", full=True, iid=3)
579+
body = [ln for ln in lines if ln.startswith(" M")]
580+
assert len(body) == mr.NAMESTATUS_FETCH_CAP # full = uncapped display
581+
assert lines[-1] == f" … +{1200 - mr.NAMESTATUS_FETCH_CAP} more (output capped at {mr.NAMESTATUS_FETCH_CAP} files)"

0 commit comments

Comments
 (0)