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
2 changes: 1 addition & 1 deletion .claude/hooks/user_prompt_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def main() -> None:
if issue is None:
continue

labels = ", ".join(l.get("name", "") for l in (issue.get("labels") or []))
labels = ", ".join(lbl.get("name", "") for lbl in (issue.get("labels") or []))
body = (issue.get("body") or "").strip()
if len(body) > 1500:
body = body[:1500] + "\n…(truncated)"
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `ossmate doctor` subcommand — runs 6 environment diagnostics (Python version, `ossmate` install, `gh` CLI + auth, MCP server reachability via `python -m ossmate_mcp --selftest`, project root detection, `.ossmate/` artifact dir writability) and reports ✓/⚠/✗ with remediation hints. Exits 0 when healthy, 1 on any hard failure. Supports `--json` for CI use. Motivated by Phase 9 onboarding-friction review

### Fixed
- `ossmate version` and `ossmate_mcp.__version__` resolved to the hardcoded literal `"0.0.1"` instead of the installed package version. Both `__init__.py` modules now read from `importlib.metadata.version()` so they always agree with what `pip show` reports. New invariant test [tests/test_versioning.py](tests/test_versioning.py)::`test_init_modules_dont_hardcode_versions` forbids the hardcoded form going forward — caught the day after v0.1.0 shipped to PyPI

Expand Down
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ ossmate triage-pr 1234 --dry-run # print the rendered prompt + ClaudeAgentOpt

The CLI loads the same `.claude/commands/*.md` skill bodies the plugin uses — write a skill once, get a slash command and a CLI subcommand for free.

If your first command fails, run `ossmate doctor` to diagnose the environment (Python, `gh` CLI auth, MCP server, `.claude/` / `.ossmate/` directories). Pass `--json` for CI-friendly output.

### C. From source (development)

```bash
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ ossmate triage-pr 1234 --dry-run # 렌더링된 prompt + ClaudeAgentOptions

CLI는 플러그인이 사용하는 동일한 `.claude/commands/*.md` 스킬 본문을 로드합니다 — 스킬을 한 번 작성하면 슬래시 명령과 CLI 서브커맨드가 자동으로 함께 생깁니다.

설치 후 첫 명령이 실패한다면 `ossmate doctor`로 환경(Python, `gh` CLI 인증, MCP 서버, `.claude/`·`.ossmate/` 디렉터리)을 먼저 진단하세요. `--json` 플래그로 CI에서도 사용 가능합니다.

### C. 소스에서 (개발용)

```bash
Expand Down
5 changes: 5 additions & 0 deletions cli/ossmate/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM"]

[tool.ruff.lint.per-file-ignores]
# Typer's idiom calls `_common_cwd()` etc. in default values; a module-level
# singleton wouldn't work because Typer re-parses OptionInfo at call time.
"src/ossmate/cli.py" = ["B008"]

[tool.mypy]
python_version = "3.11"
strict = true
3 changes: 2 additions & 1 deletion cli/ossmate/src/ossmate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Ossmate — Claude-powered co-maintainer CLI."""

from importlib.metadata import PackageNotFoundError, version as _pkg_version
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version

try:
__version__ = _pkg_version("ossmate")
Expand Down
36 changes: 32 additions & 4 deletions cli/ossmate/src/ossmate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from .prompts import SkillNotFoundError, load_skill
from .tools.repo import ProjectRootNotFoundError, find_project_root


app = typer.Typer(
name="ossmate",
help="OSS Maintainer's co-pilot — runs the same workflows as the Claude Code "
Expand Down Expand Up @@ -120,7 +119,10 @@ def release_notes(
_dispatch("release-notes", args, dry_run=dry_run, cwd=cwd, model=model)


@app.command("stale-sweep", help="Find issues older than N days and propose nudge / close / wontfix.")
@app.command(
"stale-sweep",
help="Find issues older than N days and propose nudge / close / wontfix.",
)
def stale_sweep(
days: int = typer.Option(60, "--days", help="Inactivity threshold in days."),
label: str | None = typer.Option(None, "--label", help="Restrict to a single label."),
Expand All @@ -134,7 +136,10 @@ def stale_sweep(
_dispatch("stale-sweep", args, dry_run=dry_run, cwd=cwd, model=model)


@app.command("onboard-contributor", help="Draft a warm welcome comment for a first-time contributor.")
@app.command(
"onboard-contributor",
help="Draft a warm welcome comment for a first-time contributor.",
)
def onboard_contributor(
number: str = typer.Argument(..., help="PR or issue number."),
dry_run: bool = _common_dry_run(),
Expand Down Expand Up @@ -178,7 +183,10 @@ def security_review_pr(
_dispatch("security-review-pr", args, dry_run=dry_run, cwd=cwd, model=model)


@app.command("changelog-bump", help="Inspect Conventional Commits and propose the next semver bump.")
@app.command(
"changelog-bump",
help="Inspect Conventional Commits and propose the next semver bump.",
)
def changelog_bump(
since: str | None = typer.Option(None, "--since", help="Ref or ISO date to diff from."),
dry_run: bool = _common_dry_run(),
Expand All @@ -196,6 +204,26 @@ def version_cmd() -> None:
sys.stdout.write(f"ossmate {__version__}\n")


@app.command("doctor", help="Run diagnostic checks for the Ossmate environment.")
def doctor(
json_output: bool = typer.Option(
False,
"--json",
help="Emit machine-readable JSON instead of pretty output.",
),
cwd: Path | None = _common_cwd(),
) -> None:
from .diagnostics import render_json, render_pretty, run_all

results = run_all(cwd)
if json_output:
sys.stdout.write(render_json(results) + "\n")
else:
render_pretty(results)
if any(r.status == "fail" for r in results):
raise typer.Exit(1)


def main() -> None:
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
Expand Down
211 changes: 211 additions & 0 deletions cli/ossmate/src/ossmate/diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""Ossmate environment diagnostics — 6 checks that self-diagnose ~80% of
first-run failures (missing `gh`, unauthed `gh`, missing `.claude/`, broken
MCP install, `.ossmate/` permission issues).

Each check is a pure `(project_root: Path | None) -> CheckResult` function so
it can be unit-tested in isolation. `run_all` composes them in a fixed order.
"""

from __future__ import annotations

import json
import shutil
import subprocess
import sys
from dataclasses import asdict, dataclass
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
from pathlib import Path
from typing import Literal

from .tools.repo import ProjectRootNotFoundError, find_project_root

Status = Literal["ok", "warn", "fail"]


@dataclass(frozen=True)
class CheckResult:
name: str
status: Status
detail: str
hint: str = ""


MIN_PYTHON = (3, 11)
MCP_SELFTEST_TIMEOUT_S = 10
GH_AUTH_TIMEOUT_S = 5


def check_python(_project_root: Path | None) -> CheckResult:
v = sys.version_info
detail = f"{v.major}.{v.minor}.{v.micro}"
if (v.major, v.minor) >= MIN_PYTHON:
return CheckResult("python", "ok", detail)
return CheckResult(
"python",
"fail",
detail,
hint=f"Ossmate requires Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+",
)


def check_ossmate(_project_root: Path | None) -> CheckResult:
try:
v = _pkg_version("ossmate")
except PackageNotFoundError:
return CheckResult(
"ossmate",
"fail",
"not installed",
hint="Install with `pipx install ossmate`",
)
return CheckResult("ossmate", "ok", v)


def check_gh(_project_root: Path | None) -> CheckResult:
gh_path = shutil.which("gh")
if gh_path is None:
return CheckResult(
"gh cli",
"warn",
"not found",
hint=(
"Install from https://cli.github.com/ — Ossmate falls back to "
"PyGithub but `gh` speeds up PR/issue commands"
),
)
try:
result = subprocess.run(
[gh_path, "auth", "status"],
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=GH_AUTH_TIMEOUT_S,
check=False,
)
except (subprocess.TimeoutExpired, OSError) as exc:
return CheckResult(
"gh cli",
"warn",
f"auth probe failed ({type(exc).__name__})",
hint="Run `gh auth login` and retry",
)
if result.returncode == 0:
return CheckResult("gh cli", "ok", "authenticated")
return CheckResult(
"gh cli",
"warn",
"not authenticated",
hint="Run `gh auth login` to enable PR/issue commands",
)


def check_mcp_server(_project_root: Path | None) -> CheckResult:
try:
result = subprocess.run(
[sys.executable, "-X", "utf8", "-m", "ossmate_mcp", "--selftest"],
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=MCP_SELFTEST_TIMEOUT_S,
check=False,
)
except subprocess.TimeoutExpired:
return CheckResult(
"mcp server",
"fail",
f"selftest timed out after {MCP_SELFTEST_TIMEOUT_S}s",
hint="Reinstall with `pipx install ossmate-mcp`",
)
except OSError as exc:
return CheckResult(
"mcp server",
"fail",
f"could not spawn Python ({exc})",
hint="Reinstall with `pipx install ossmate-mcp`",
)
if result.returncode != 0:
stderr_tail = (result.stderr or "").strip().splitlines()[-1:] or [""]
return CheckResult(
"mcp server",
"fail",
f"selftest exit code {result.returncode}: {stderr_tail[0]}",
hint="Reinstall with `pipx install ossmate-mcp`",
)
first_line = (result.stdout or "").strip().splitlines()[:1] or [""]
return CheckResult("mcp server", "ok", first_line[0] or "selftest ok")


def check_project_root(project_root: Path | None) -> CheckResult:
try:
root = find_project_root(project_root)
except ProjectRootNotFoundError:
return CheckResult(
"project root",
"warn",
"no `.claude/commands/` above cwd",
hint="Run from inside a repo with `.claude/commands/`",
)
return CheckResult("project root", "ok", str(root))


def check_ossmate_writable(project_root: Path | None) -> CheckResult:
try:
root = find_project_root(project_root)
except ProjectRootNotFoundError:
return CheckResult(
".ossmate writable",
"warn",
"skipped — no project root",
hint="This check runs once `project root` resolves",
)
artifacts = root / ".ossmate"
probe = artifacts / ".write-probe"
try:
artifacts.mkdir(parents=True, exist_ok=True)
probe.write_text("ok", encoding="utf-8")
probe.unlink()
except OSError as exc:
return CheckResult(
".ossmate writable",
"fail",
f"{artifacts}: {exc.strerror or exc}",
hint="Check filesystem permissions on the project root",
)
return CheckResult(".ossmate writable", "ok", str(artifacts))


# Ordered so output reads top-down: runtime → package → external → server → repo.
_CHECKS = (
check_python,
check_ossmate,
check_gh,
check_mcp_server,
check_project_root,
check_ossmate_writable,
)


def run_all(project_root: Path | None) -> list[CheckResult]:
return [c(project_root) for c in _CHECKS]


def render_pretty(results: list[CheckResult]) -> None:
from rich.console import Console

console = Console()
glyph = {
"ok": "[green]✓[/green]",
"warn": "[yellow]⚠[/yellow]",
"fail": "[red]✗[/red]",
}
for r in results:
console.print(f"{glyph[r.status]} {r.name:<18} {r.detail}")
if r.hint and r.status != "ok":
console.print(f" [dim]→ {r.hint}[/dim]")


def render_json(results: list[CheckResult]) -> str:
return json.dumps(
{"checks": [asdict(r) for r in results]}, indent=2, ensure_ascii=False
)
1 change: 0 additions & 1 deletion cli/ossmate/src/ossmate/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from dataclasses import dataclass, field
from pathlib import Path


_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL)
_KV_RE = re.compile(r"^([A-Za-z][\w-]*)\s*:\s*(.*)$")
_PLACEHOLDER_RE = re.compile(r"\$(\d+|ARGUMENTS)")
Expand Down
3 changes: 2 additions & 1 deletion mcp/ossmate_mcp/src/ossmate_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Ossmate MCP server — OSS maintainer tools exposed via Model Context Protocol."""

from importlib.metadata import PackageNotFoundError, version as _pkg_version
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version

try:
__version__ = _pkg_version("ossmate-mcp")
Expand Down
6 changes: 3 additions & 3 deletions mcp/ossmate_mcp/src/ossmate_mcp/tools/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import os
import shutil
import subprocess
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any

from mcp.server.fastmcp import FastMCP
Expand Down Expand Up @@ -190,7 +190,7 @@ def list_stale_issues(
])
if data is None:
return {"error": "gh_issue_list_failed"}
cutoff = datetime.now(tz=timezone.utc).timestamp() - days * 86400
cutoff = datetime.now(tz=UTC).timestamp() - days * 86400
stale = []
for issue in data:
updated = issue.get("updatedAt")
Expand All @@ -202,7 +202,7 @@ def list_stale_issues(
continue
if ts <= cutoff:
issue["age_days"] = int(
(datetime.now(tz=timezone.utc).timestamp() - ts) / 86400
(datetime.now(tz=UTC).timestamp() - ts) / 86400
)
stale.append(issue)
return {"count": len(stale), "threshold_days": days, "issues": stale}
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ def test_every_skill_has_a_subcommand(self):
def test_no_orphan_subcommands(self):
"""A subcommand without a matching skill file is dead code."""
skill_names = {p.stem for p in COMMANDS_DIR.glob("*.md")}
# `version` is the one CLI-only command we expect.
cli_only_allowlist = {"version"}
# `version` and `doctor` are CLI-only commands with no slash equivalent.
cli_only_allowlist = {"version", "doctor"}
registered = _registered_subcommands() - cli_only_allowlist
orphans = registered - skill_names
assert not orphans, (
Expand Down
Loading
Loading