Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/presets/gitlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
4 changes: 2 additions & 2 deletions presets/gitlab.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
97 changes: 97 additions & 0 deletions presets/gitlab/mr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]")
Expand Down Expand Up @@ -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:
Expand Down
152 changes: 152 additions & 0 deletions tests/test_gitlab_mr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Loading