diff --git a/CHANGELOG.md b/CHANGELOG.md index aa386a4..5ad62e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`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). - **`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). - **`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). - **`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). diff --git a/README.md b/README.md index b95bdd7..7c98c29 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ supertool 'read:src/Module.py' 'read:src/Auth.py' 'grep:TODO:src/:20' 'map:src/' **Drill in 2026.** supertool gives the agent variants that pack the *next question* into the *current call*: - **`git-status`** — branch + tracking + ahead/behind + dirty files + open MR/PR + suggested next step. One call, decision ready. -- **`gl-mr:NUMBER`** / **`gh-pr:NUMBER`** — full MR/PR dashboard: branch, pipeline, reviewer, approval, diff stat, comments. Replaces 4-5 `glab`/`gh` calls. +- **`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. - **`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. - **`claude-log-summary:UUID`** — model, duration, tool calls, tokens, cache hit %, errors-by-tool. Audit your own runs. diff --git a/docs/presets/gitlab.md b/docs/presets/gitlab.md index c463ed9..4b88e39 100644 --- a/docs/presets/gitlab.md +++ b/docs/presets/gitlab.md @@ -11,7 +11,7 @@ GitLab ops via the `glab` CLI. Replaces the 3-5 separate `glab` calls needed to | Op | Syntax | What it returns | |----|--------|-----------------| | `gl-issue` | `gl-issue:NUMBER[:full]` | Issue metadata, description, comments (truncated by default), related MRs. `:full` disables truncation | -| `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 | +| `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 | | `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) | | `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") | | `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) | diff --git a/presets/gitlab.json b/presets/gitlab.json index d66becc..e4da078 100644 --- a/presets/gitlab.json +++ b/presets/gitlab.json @@ -11,8 +11,8 @@ "gl-mr": { "cmd": "{python} {path}gitlab/mr.py {args}", "timeout": 20, - "description": "MR review: branch, pipeline, approval, linked issue, diff stat, comments. :status = slim merge-state only", - "syntax": "gl-mr:NUMBER_OR_BRANCH[:status]" + "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", + "syntax": "gl-mr:NUMBER_OR_BRANCH[:status|:full]" }, "gl-mrs": { "cmd": "{python} {path}gitlab/mrs.py {args}", diff --git a/presets/gitlab/mr.py b/presets/gitlab/mr.py index 5804cf6..bbc1b84 100644 --- a/presets/gitlab/mr.py +++ b/presets/gitlab/mr.py @@ -15,6 +15,8 @@ COMMENT_MAX = 500 COMMENT_TOTAL_MAX = 2000 TAIL_COMMENTS = 2 +NAMESTATUS_DISPLAY_MAX = 50 +NAMESTATUS_FETCH_CAP = 500 def _relative_age(iso: str) -> str: @@ -241,6 +243,95 @@ def _budgeted_comments(notes: list, budget: int, tail: int) -> tuple[list[str], return head_kept + (["__GAP__"] if hidden else []) + tail_slice, len(hidden), hidden_bytes +def _name_status_flag(f: dict) -> str: + """Map a GitLab diff entry to a one-char change type (A/D/R/M).""" + if f.get("new_file"): + return "A" + if f.get("deleted_file"): + return "D" + if f.get("renamed_file"): + return "R" + return "M" + + +def _get_name_status(iid: str | int, fetch_all: bool) -> list[tuple[str, str]]: + """Return per-file (flag, path) for an MR via the paginated diffs endpoint. + + Default fetches only the first page (100 files) — enough for the display + cap. With fetch_all (gl-mr:N:full) it paginates up to NAMESTATUS_FETCH_CAP + files. Returns [] on any API/parse failure so the caller silently omits + the block rather than erroring. + """ + entries: list[tuple[str, str]] = [] + page = 1 + while True: + try: + r = _glab_api( + f"projects/:id/merge_requests/{iid}/diffs?per_page=100&page={page}" + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + break + if r.returncode != 0: + break + try: + diffs = json.loads(r.stdout) + except json.JSONDecodeError: + break + if not isinstance(diffs, list) or not diffs: + break + for f in diffs: + flag = _name_status_flag(f) + new_path = f.get("new_path") or "" + old_path = f.get("old_path") or "" + if flag == "R" and old_path and new_path and old_path != new_path: + path = f"{old_path} → {new_path}" + else: + path = new_path or old_path or "?" + entries.append((flag, path)) + if not fetch_all or len(diffs) < 100 or len(entries) >= NAMESTATUS_FETCH_CAP: + break + page += 1 + return entries + + +def _coerce_count(changes: object) -> int | None: + """Return the leading integer of GitLab's changes_count. + + changes_count comes back as a string — "18" on normal MRs, "1000+" when + capped. Returns the leading int (1000 for "1000+"), or None when there are + no leading digits, so callers can fall back to the fetched-entry count. + """ + m = re.match(r"\d+", str(changes)) + return int(m.group()) if m else None + + +def _render_name_status( + entries: list[tuple[str, str]], changes: object, full: bool, iid: str | int +) -> list[str]: + """Build the '## Files' block lines from name-status entries. + + Returns [] when there are no entries (caller omits the block). The total + file count drives the "+N more" overflow line: it comes from changes_count + (authoritative, survives the display cap and single-page fetch) and falls + back to the fetched count when changes_count is missing or smaller. + """ + if not entries: + return [] + shown = entries if full else entries[:NAMESTATUS_DISPLAY_MAX] + total = _coerce_count(changes) + if total is None or total < len(entries): + total = len(entries) + lines = [f"\n## Files ({changes})"] + lines.extend(f" {flag} {path}" for flag, path in shown) + hidden = total - len(shown) + if hidden > 0: + if full: + lines.append(f" … +{hidden} more (output capped at {NAMESTATUS_FETCH_CAP} files)") + else: + lines.append(f" … +{hidden} more (use gl-mr:{iid}:full)") + return lines + + def main() -> int: if len(sys.argv) < 2: print("ERROR: usage: mr.py NUMBER [status|full]") @@ -500,6 +591,12 @@ def _pipe_meta(pipeline: dict) -> str: # glab mr view omits diff_stats on large MRs (typically 1000+ files) print(f"Changes: {changes} files (line counts unavailable on large MRs)") + # File-level name-status — the deletion/addition list is the high-signal + # scan when reviewing an MR. Default-capped; gl-mr:N:full uncaps. + if changes: + for line in _render_name_status(_get_name_status(iid, full), changes, full, iid): + print(line) + # Conflicts conflict_files: list[str] = [] if has_conflicts: diff --git a/tests/test_gitlab_mr.py b/tests/test_gitlab_mr.py index fa1cb2b..fc5dbb8 100644 --- a/tests/test_gitlab_mr.py +++ b/tests/test_gitlab_mr.py @@ -427,3 +427,155 @@ def test_budgeted_comments_hidden_bytes_counts_utf8() -> None: _, hidden_count, hidden_bytes = mr._budgeted_comments(notes, budget=2000, tail=2) assert hidden_count > 0 assert hidden_bytes == hidden_count * rendered_one_bytes + + +# --------------------------------------------------------------------------- +# _name_status_flag / _get_name_status +# --------------------------------------------------------------------------- + +def test_name_status_flag_mapping() -> None: + assert mr._name_status_flag({"new_file": True}) == "A" + assert mr._name_status_flag({"deleted_file": True}) == "D" + assert mr._name_status_flag({"renamed_file": True}) == "R" + assert mr._name_status_flag({}) == "M" + + +def _api_json(payload: Any, returncode: int = 0) -> Any: + import json as _json + return subprocess.CompletedProcess( + args=["glab"], returncode=returncode, stdout=_json.dumps(payload), stderr="" + ) + + +def test_get_name_status_parses_flags_and_paths(monkeypatch) -> None: + diffs = [ + {"new_file": True, "new_path": "a.py", "old_path": "a.py"}, + {"deleted_file": True, "new_path": "b.py", "old_path": "b.py"}, + {"renamed_file": True, "new_path": "d.py", "old_path": "c.py"}, + {"new_path": "e.py", "old_path": "e.py"}, + ] + monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json(diffs)) + assert mr._get_name_status(42, fetch_all=False) == [ + ("A", "a.py"), ("D", "b.py"), ("R", "c.py → d.py"), ("M", "e.py"), + ] + + +def test_get_name_status_rename_without_path_change_shows_single(monkeypatch) -> None: + """A renamed_file flag with identical paths (mode-only change) shows one path.""" + diffs = [{"renamed_file": True, "new_path": "x.py", "old_path": "x.py"}] + monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json(diffs)) + assert mr._get_name_status(1, fetch_all=False) == [("R", "x.py")] + + +def test_get_name_status_deleted_uses_old_path_when_new_missing(monkeypatch) -> None: + diffs = [{"deleted_file": True, "new_path": "", "old_path": "gone.py"}] + monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json(diffs)) + assert mr._get_name_status(1, fetch_all=False) == [("D", "gone.py")] + + +def test_get_name_status_api_failure_returns_empty(monkeypatch) -> None: + monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json([], returncode=1)) + assert mr._get_name_status(1, fetch_all=False) == [] + + +def test_get_name_status_bad_json_returns_empty(monkeypatch) -> None: + bad = subprocess.CompletedProcess(args=["glab"], returncode=0, stdout="not json", stderr="") + monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: bad) + assert mr._get_name_status(1, fetch_all=False) == [] + + +def test_get_name_status_timeout_returns_empty(monkeypatch) -> None: + def boom(*a: Any, **kw: Any) -> Any: + raise subprocess.TimeoutExpired(cmd="glab", timeout=10) + monkeypatch.setattr(mr, "_glab_api", boom) + assert mr._get_name_status(1, fetch_all=False) == [] + + +def test_get_name_status_single_page_when_not_fetch_all(monkeypatch) -> None: + """A full page (100) must NOT trigger a second fetch unless fetch_all.""" + calls = [] + full_page = [{"new_path": f"f{i}.py", "old_path": f"f{i}.py"} for i in range(100)] + + def fake(endpoint: str, *a: Any, **kw: Any) -> Any: + calls.append(endpoint) + return _api_json(full_page) + + monkeypatch.setattr(mr, "_glab_api", fake) + entries = mr._get_name_status(1, fetch_all=False) + assert len(entries) == 100 + assert len(calls) == 1 + + +def test_get_name_status_paginates_when_fetch_all(monkeypatch) -> None: + """fetch_all walks pages until a short page signals the end.""" + page1 = [{"new_path": f"p1_{i}.py", "old_path": f"p1_{i}.py"} for i in range(100)] + page2 = [{"new_path": "p2_0.py", "old_path": "p2_0.py"}] # short page -> stop + + def fake(endpoint: str, *a: Any, **kw: Any) -> Any: + return _api_json(page1 if "&page=1" in endpoint else page2) + + monkeypatch.setattr(mr, "_glab_api", fake) + entries = mr._get_name_status(1, fetch_all=True) + assert len(entries) == 101 + assert entries[-1] == ("M", "p2_0.py") + + +def test_get_name_status_respects_fetch_cap(monkeypatch) -> None: + """fetch_all stops once NAMESTATUS_FETCH_CAP files are collected.""" + full_page = [{"new_path": f"f{i}.py", "old_path": f"f{i}.py"} for i in range(100)] + monkeypatch.setattr(mr, "_glab_api", lambda *a, **kw: _api_json(full_page)) + entries = mr._get_name_status(1, fetch_all=True) + assert len(entries) == mr.NAMESTATUS_FETCH_CAP + + +# --------------------------------------------------------------------------- +# _coerce_count / _render_name_status (display-block math) +# --------------------------------------------------------------------------- + +def test_coerce_count_handles_string_and_capped() -> None: + assert mr._coerce_count("18") == 18 + assert mr._coerce_count("1000+") == 1000 # GitLab caps large MRs as "N+" + assert mr._coerce_count(42) == 42 + assert mr._coerce_count("") is None + assert mr._coerce_count(None) is None + + +def test_render_name_status_empty_entries_returns_nothing() -> None: + assert mr._render_name_status([], "0", full=False, iid=1) == [] + + +def test_render_name_status_under_cap_no_overflow() -> None: + entries = [("A", "a.py"), ("D", "b.py")] + lines = mr._render_name_status(entries, "2", full=False, iid=7) + assert lines == ["\n## Files (2)", " A a.py", " D b.py"] + assert not any("more" in ln for ln in lines) + + +def test_render_name_status_overflow_uses_changes_count_not_fetched() -> None: + """The +N more count must come from changes_count, not the fetched page. + + Regression: changes_count is a *string* ("200"), so an isinstance(int) + guard always fell through to the fetched-entry count — undercounting the + overflow on >100-file MRs. Here 100 fetched, 50 shown, true total 200. + """ + entries = [("M", f"f{i}.py") for i in range(100)] + lines = mr._render_name_status(entries, "200", full=False, iid=9) + assert lines[0] == "\n## Files (200)" + assert len([ln for ln in lines if ln.startswith(" M")]) == mr.NAMESTATUS_DISPLAY_MAX + assert lines[-1] == f" … +{200 - mr.NAMESTATUS_DISPLAY_MAX} more (use gl-mr:9:full)" + + +def test_render_name_status_falls_back_to_len_when_count_smaller() -> None: + """If changes_count is missing/smaller than fetched, use the fetched count.""" + entries = [("A", f"a{i}.py") for i in range(60)] + lines = mr._render_name_status(entries, "", full=False, iid=1) + assert lines[-1] == f" … +{60 - mr.NAMESTATUS_DISPLAY_MAX} more (use gl-mr:1:full)" + + +def test_render_name_status_full_mode_cap_message() -> None: + """In full mode an overflow points at the fetch cap, not :full again.""" + entries = [("M", f"f{i}.py") for i in range(mr.NAMESTATUS_FETCH_CAP)] + lines = mr._render_name_status(entries, "1200", full=True, iid=3) + body = [ln for ln in lines if ln.startswith(" M")] + assert len(body) == mr.NAMESTATUS_FETCH_CAP # full = uncapped display + assert lines[-1] == f" … +{1200 - mr.NAMESTATUS_FETCH_CAP} more (output capped at {mr.NAMESTATUS_FETCH_CAP} files)"