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
5 changes: 5 additions & 0 deletions .supertool.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"adviseForNewTest": false,
"validator_cache": true,
"validator_cache_ttl_hours": 24,
"_comment_defaults": "Default project/repo for payload ops that would otherwise require one. gl-issue-create reads 'gitlab_project' when the payload omits 'project'; gh-issue-create reads 'github_repo' when it omits 'repo'. Resolution order: explicit payload field > this config > the 'origin' git remote (host-matched). Omit this block to rely on git-remote auto-detect alone.",
"defaults": {
"gitlab_project": "fdavid/dvsi",
"github_repo": "Digital-Process-Tools/claude-supertool"
},
"presets": [
"git",
"github",
Expand Down
1 change: 1 addition & 0 deletions .supertool.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"defaults": { "github_repo": "Digital-Process-Tools/claude-supertool" },
"compact": false,
"rtk": false,
"allow_outside_cwd": true,
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`gl-mrs` — MR triage board.** Lists MRs (default `author=@me`), sorted failing-first then stalest, each enriched in parallel with everything you'd otherwise round-trip for: pipeline status (a failure shows the failed **job name** — `phpstan2`, `test_unit_dpt` — i.e. the failure class, pre-empting a `gl-job` call), approval state, age, diff size, watch-state cross-reference (which MRs already have a live `watch` poller), and `conflict`/`draft`/`threads` flags. Footer points at the first failing-and-unwatched MR. Filters: `author`/`reviewer`/`assignee`/`label`/`milestone`/`state`/`per`. Flags: `nopipe`, `iids` (bare id list), `failed` (only failing). Tunable via `enrich_workers`/`enrich_cap`/`per_page`. "Mine" is just the default filter, not a special op — enumeration is a platform concern, kept out of the generic `watch` preset. See [docs/presets/gitlab.md](docs/presets/gitlab.md).
- **`gh-prs` — PR triage board (the `gl-mrs` twin).** Lists GitHub PRs (default `author=@me`), sorted failing-first then stalest. Per PR: check rollup (a failure shows the failing **check name** — the failure class, pre-empting a `gh-job` call), approval state (`reviewDecision`), age, diff size, watch-state cross-reference, and `draft`/`conflict`/`threads` flags. Footer points at the first failing-and-unwatched PR. Unlike GitLab, `gh pr list --json` returns checks + approval in **one** call, so the core board costs a single request; only unresolved review-thread counts enrich in a second parallel wave (skip with `nopipe`). Filters: `author`/`reviewer`/`assignee`/`label`/`state`/`per`. Flags: `nopipe`, `iids` (bare number list), `failed`. Closes [#274](https://github.com/Digital-Process-Tools/claude-supertool/issues/274). See [docs/presets/github.md](docs/presets/github.md).
- **`presets/watch/watch-mine.sh` — watch-a-whole-query supervisor.** Glue between a "list mine" op (`gl-mrs`/`gh-prs`) and the `watch` preset: runs the feed op, extracts ids, spawns one watcher each. Idempotent, so it's safe on `/loop`. `gl-mrs:failed,iids | watch` turns "ping me when any of my MRs goes red" into one looped command. See [docs/presets/watch.md](docs/presets/watch.md).
- **Configurable default `project` / `repo` for issue-create payloads.** `gl-issue-create` and `gh-issue-create` no longer require `project`/`repo` in every payload. Resolution order (most specific wins): explicit payload field → `defaults.gitlab_project` / `defaults.github_repo` in `.supertool.json` → the `origin` git remote, host-matched (`gitlab` / `github.com`). In the matching checkout the remote covers it with zero config. Closes [#270](https://github.com/Digital-Process-Tools/claude-supertool/issues/270). See [docs/presets/gitlab.md](docs/presets/gitlab.md) and [docs/presets/github.md](docs/presets/github.md).
- **`watch` preset — background event pollers with async wake into Claude Code.** New ops `watch:SOURCE:ID[:only=...]`, `unwatch:SOURCE:ID`, `watches`. Each invocation forks a detached poller that emits state-change events to a UDS socket, a status file, and macOS Notification Center. Source-plugin contract makes new sources ~50 lines. Bundled sources: `github-pr` (PR state, checks, reviews, comments, merge/close, conflicts) and `gitlab-mr` (MR state, pipeline, merge/close, conflicts). Closes [#165](https://github.com/Digital-Process-Tools/claude-supertool/issues/165). See [docs/presets/watch.md](docs/presets/watch.md).
- **`notifiers/claude-channel/` — MCP channel server for async wake.** TypeScript / Bun server that binds the watch UDS socket and pushes events into a running Claude Code session via the Channels feature (research preview, requires Claude Code v2.1.80+). Events arrive as `<channel source="claude-channel" watcher_source="<source>" id="<id>" event="<event>" ...>` tags. UDS file mode 0600 + localhost-only auth model. Launch with `claude --dangerously-load-development-channels server:claude-channel`. See [notifiers/claude-channel/README.md](notifiers/claude-channel/README.md).
- **`glob` brace expansion.** `glob:src/**/*.{json,xml}` now fans out into `*.json` + `*.xml` (shell/fd/ripgrep semantics) and dedupes results, instead of silently returning 0 files. Supports multiple groups (`{a,b}.{x,y}` → 4) and nesting (`{a,b{1,2}}` → 3). Patterns without braces are unchanged. Closes [#161](https://github.com/Digital-Process-Tools/claude-supertool/issues/161).
Expand Down
14 changes: 14 additions & 0 deletions docs/presets/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ GitHub ops via the `gh` CLI. Replaces the 4-6 separate `gh` calls needed to revi

Otherwise inherits from `gh auth status` — no project-specific tokens needed.

### Default repo for `gh-issue-create`

`gh-issue-create` needs a `repo` in every payload. To omit it, set a default:

```json
{
"defaults": {
"github_repo": "Digital-Process-Tools/claude-supertool"
}
}
```

Resolution order (most specific wins): explicit `repo` in the payload → `defaults.github_repo` → the `origin` git remote when its host is `github.com`. In a GitHub checkout the remote covers it with zero config; the explicit default is for when `origin` points elsewhere.

## Authoring notes

Preset JSON: `presets/github.json`. Helper scripts: `presets/github/` — one Python file per op. `gh-find-followable` and `gh-find-starable` are discovery ops: they produce a list for human review, not an immediate action. Always review the file before running `gh-batch-follow` or `gh-batch-star`.
14 changes: 14 additions & 0 deletions docs/presets/gitlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ The list endpoint omits pipeline, approval, and diff data, so each MR costs a co

Otherwise inherits from `glab auth status` — no project-specific tokens needed.

### Default project for `gl-issue-create`

`gl-issue-create` needs a `project` in every payload. To omit it, set a default:

```json
{
"defaults": {
"gitlab_project": "fdavid/dvsi"
}
}
```

Resolution order (most specific wins): explicit `project` in the payload → `defaults.gitlab_project` → the `origin` git remote when its host contains `gitlab`. In a GitLab checkout the remote covers it with zero config; the explicit default is for when `origin` points elsewhere.

## Authoring notes

Preset JSON: `presets/gitlab.json`. Helper scripts: `presets/gitlab/`. `gl-mr` accepts either an MR number or a branch name — it resolves branches to MRs automatically.
118 changes: 118 additions & 0 deletions presets/_remote_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Resolve a default GitLab project / GitHub repo for preset payload ops.

Shared by ``gitlab/issue_create.py`` (``project``) and
``github/issue_create.py`` (``repo``) so the common case needs no per-payload
boilerplate.

Resolution order (most specific wins):

1. explicit value in the payload (handled by the caller)
2. ``defaults.<config_key>`` in ``.supertool.json`` (cwd or any parent)
3. the ``origin`` git remote, when its host matches the platform

Returns ``None`` when nothing resolves, so the caller raises its own
"missing required field" error unchanged.
"""
from __future__ import annotations

import json
import re
import subprocess
from pathlib import Path


def _run_git(args: list[str], timeout: int = 5) -> str | None:
"""Run a git command, returning trimmed stdout or None on any failure."""
try:
result = subprocess.run(
["git"] + args,
capture_output=True, text=True, timeout=timeout,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return None
if result.returncode != 0:
return None
return result.stdout.strip() or None


def config_default(key: str) -> str | None:
"""Return ``defaults[key]`` from the nearest .supertool.json, or None.

Walks from cwd up to the filesystem root. The first .supertool.json found
is authoritative — a malformed file or missing ``defaults`` block yields
None rather than continuing the search (mirrors core's single-file load).
"""
cwd = Path.cwd()
for directory in [cwd, *cwd.parents]:
candidate = directory / ".supertool.json"
if not candidate.is_file():
continue
try:
data = json.loads(candidate.read_text())
except (json.JSONDecodeError, OSError):
return None
if not isinstance(data, dict):
return None
defaults = data.get("defaults")
if isinstance(defaults, dict):
val = defaults.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
return None
return None


def parse_remote(url: str) -> tuple[str, str] | None:
"""Parse a git remote URL into ``(host, 'namespace/repo')``.

Handles the three forms git emits:
scp-like git@host:namespace/repo.git
ssh:// ssh://git@host[:port]/namespace/repo.git
https:// https://host[:port]/namespace/repo.git

Returns None when the URL matches none of them.
"""
url = url.strip()
if not url:
return None
# scp-like: user@host:path (no scheme, single colon before the path)
scp = re.match(r"^[\w.+-]+@([^:/]+):(.+)$", url)
if scp:
host, path = scp.group(1), scp.group(2)
else:
# scheme://[user@]host[:port]/path
uri = re.match(r"^[a-zA-Z][\w+.-]*://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+)$", url)
if not uri:
return None
host, path = uri.group(1), uri.group(2)
path = path.strip("/")
if path.endswith(".git"):
path = path[:-4]
path = path.strip("/")
if not host or not path:
return None
return host, path


def origin_slug(host_substr: str) -> str | None:
"""Return the ``origin`` remote's 'ns/repo' when its host matches.

``host_substr`` is matched as a substring of the remote host so a
self-hosted GitLab (``gitlab.dp.tools``) matches ``"gitlab"``.
"""
url = _run_git(["remote", "get-url", "origin"])
if not url:
return None
parsed = parse_remote(url)
if parsed is None:
return None
host, path = parsed
if host_substr in host:
return path
return None


def resolve(config_key: str, host_substr: str) -> str | None:
"""Config default wins over git-remote auto-detect. None if neither hits."""
return config_default(config_key) or origin_slug(host_substr)
8 changes: 8 additions & 0 deletions presets/github/issue_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
except ModuleNotFoundError:
tomllib = None # type: ignore[assignment]

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import _remote_default as _rd # noqa: E402


def _gh(args: list[str], timeout: int = 20) -> subprocess.CompletedProcess[str]:
return subprocess.run(
Expand Down Expand Up @@ -68,6 +71,11 @@ def main() -> int:
print(f"ERROR: failed to parse payload: {e}")
return 1

if not payload.get("repo"):
auto = _rd.resolve("github_repo", "github.com")
if auto:
payload["repo"] = auto

err = _validate(payload)
if err:
print(err)
Expand Down
8 changes: 8 additions & 0 deletions presets/gitlab/issue_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
except ModuleNotFoundError:
tomllib = None # type: ignore[assignment]

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import _remote_default as _rd # noqa: E402


def _glab(args: list[str], timeout: int = 20) -> subprocess.CompletedProcess[str]:
return subprocess.run(
Expand Down Expand Up @@ -73,6 +76,11 @@ def main() -> int:
print(f"ERROR: failed to parse payload: {e}")
return 1

if not payload.get("project"):
auto = _rd.resolve("gitlab_project", "gitlab")
if auto:
payload["project"] = auto

err = _validate(payload)
if err:
print(err)
Expand Down
33 changes: 33 additions & 0 deletions tests/test_issue_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,29 @@ def test_error_path_propagates(self, monkeypatch, capsys, tmp_path):
def test_missing_project_field(self, monkeypatch, capsys, tmp_path):
payload_file = _write_payload(tmp_path, {"title": "No project"})
monkeypatch.setattr(sys, "argv", ["issue_create.py", payload_file])
# No config default and no matching git remote → still an error.
monkeypatch.setattr(gl._rd, "resolve", lambda *a, **k: None)

rc = gl.main()
out = capsys.readouterr().out
assert rc != 0
assert "project" in out

def test_project_defaulted_when_missing(self, monkeypatch, capsys, tmp_path):
payload_file = _write_payload(tmp_path, {"title": "No project", "description": "x"})
monkeypatch.setattr(sys, "argv", ["issue_create.py", payload_file])
monkeypatch.setattr(gl._rd, "resolve", lambda *a, **k: "fdavid/dvsi")

captured: list[list[str]] = []
monkeypatch.setattr(gl, "_glab", lambda args, timeout=20: captured.append(args) or _ok(GL_URL))
monkeypatch.setattr(gl, "_glab_api", lambda *a, **kw: _ok("{}"))

rc = gl.main()
out = capsys.readouterr().out
assert rc == 0, out
assert "gl-issue-create OK" in out
assert _flag_value(captured[0], "--repo") == "fdavid/dvsi"

def test_missing_title_field(self, monkeypatch, capsys, tmp_path):
payload_file = _write_payload(tmp_path, {"project": "fdavid/dvsi"})
monkeypatch.setattr(sys, "argv", ["issue_create.py", payload_file])
Expand Down Expand Up @@ -452,12 +469,28 @@ def test_error_path_propagates(self, monkeypatch, capsys, tmp_path):
def test_missing_repo_field(self, monkeypatch, capsys, tmp_path):
payload_file = _write_payload(tmp_path, {"title": "No repo"})
monkeypatch.setattr(sys, "argv", ["issue_create.py", payload_file])
# No config default and no matching git remote → still an error.
monkeypatch.setattr(gh._rd, "resolve", lambda *a, **k: None)

rc = gh.main()
out = capsys.readouterr().out
assert rc != 0
assert "repo" in out

def test_repo_defaulted_when_missing(self, monkeypatch, capsys, tmp_path):
payload_file = _write_payload(tmp_path, {"title": "No repo", "body": "x"})
monkeypatch.setattr(sys, "argv", ["issue_create.py", payload_file])
monkeypatch.setattr(gh._rd, "resolve", lambda *a, **k: "Digital-Process-Tools/claude-supertool")

captured: list[list[str]] = []
monkeypatch.setattr(gh, "_gh", lambda args, timeout=20: captured.append(args) or _ok(GH_URL))

rc = gh.main()
out = capsys.readouterr().out
assert rc == 0, out
assert "gh-issue-create OK" in out
assert _flag_value(captured[0], "--repo") == "Digital-Process-Tools/claude-supertool"

def test_missing_title_field(self, monkeypatch, capsys, tmp_path):
payload_file = _write_payload(tmp_path, {"repo": "org/repo"})
monkeypatch.setattr(sys, "argv", ["issue_create.py", payload_file])
Expand Down
Loading
Loading