Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60451d7fa3 | ||
|
|
e1fd8f61f2 | ||
|
|
f0d71c3dd3 | ||
|
|
e4ddbb38af |
22 changed files with 43 additions and 493 deletions
|
|
@ -18,10 +18,6 @@ OPENCODE_SERVER_PASSWORD=your-opencode-server-password
|
||||||
# GitHub
|
# GitHub
|
||||||
GITHUB_TOKEN=your-github-token-here
|
GITHUB_TOKEN=your-github-token-here
|
||||||
|
|
||||||
# Forgejo (self-hosted) — used by status oracles with Forgejo backend
|
|
||||||
FORGEJO_URL=https://git.slaid098.dev
|
|
||||||
FORGEJO_TOKEN=your-forgejo-api-token-here
|
|
||||||
|
|
||||||
# Context7 MCP
|
# Context7 MCP
|
||||||
CONTEXT7_API_KEY=your-context7-api-key-here
|
CONTEXT7_API_KEY=your-context7-api-key-here
|
||||||
|
|
||||||
|
|
|
||||||
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
|
|
@ -35,6 +35,10 @@ jobs:
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
- run: uv sync --extra dev
|
- run: uv sync --extra dev
|
||||||
- run: uv run ruff check src/ tests/ .opencode/scripts/
|
- run: uv run ruff check src/ tests/ .opencode/scripts/
|
||||||
- run: uv run ruff format --check src/ tests/ .opencode/scripts/
|
- run: uv run ruff format --check src/ tests/ .opencode/scripts/
|
||||||
|
|
@ -46,6 +50,10 @@ jobs:
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
- run: uv sync --extra dev
|
- run: uv sync --extra dev
|
||||||
- run: uv run mypy src/
|
- run: uv run mypy src/
|
||||||
|
|
||||||
|
|
@ -57,15 +65,23 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python: ["3.13"]
|
python: ["3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
working-directory: .opencode
|
working-directory: .opencode
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
working-directory: .opencode/draw-image
|
working-directory: .opencode/draw-image
|
||||||
- run: npm test
|
- run: npm test
|
||||||
working-directory: .opencode/draw-image
|
working-directory: .opencode/draw-image
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
- run: uv sync --extra dev --python ${{ matrix.python }}
|
- run: uv sync --extra dev --python ${{ matrix.python }}
|
||||||
- run: uv run --python ${{ matrix.python }} pytest
|
- run: uv run --python ${{ matrix.python }} pytest
|
||||||
|
|
||||||
|
|
@ -76,5 +92,9 @@ jobs:
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: astral-sh/setup-uv@v3
|
||||||
- run: uv sync --extra dev
|
- run: uv sync --extra dev
|
||||||
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/
|
- run: uv run xenon --max-absolute B --max-modules A --max-average A src/
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -87,135 +85,10 @@ class PhaseResult:
|
||||||
detail: str
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_request(method: str, path: str, body: dict | None = None) -> tuple[int, str, str]:
|
|
||||||
"""Forgejo REST API call. Returns (status_code, body_text, error).
|
|
||||||
|
|
||||||
On non-2xx returns (status_code, body_text, ""). ``urlopen`` raises
|
|
||||||
HTTPError for non-2xx which carries the body; we surface it so callers
|
|
||||||
can branch on the HTTP status. Network/parse errors return (0, "", err).
|
|
||||||
"""
|
|
||||||
base = os.environ.get("FORGEJO_URL")
|
|
||||||
token = os.environ.get("FORGEJO_TOKEN")
|
|
||||||
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
|
||||||
data = None
|
|
||||||
if body is not None:
|
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
data = json.dumps(body).encode()
|
|
||||||
req = urllib.request.Request( # noqa: S310 - base URL is operator-configured
|
|
||||||
f"{base}/api/v1{path}", method=method, headers=headers, data=data
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as r: # noqa: S310 - base URL is operator-configured
|
|
||||||
return r.status, r.read().decode("utf-8", "replace"), ""
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
return e.code, e.read().decode("utf-8", "replace"), ""
|
|
||||||
except OSError as e:
|
|
||||||
return 0, "", str(e)
|
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_ci_rollup(repo: str, sha: str) -> tuple[int, str, str]:
|
|
||||||
"""Build a GitHub-style statusCheckRollup from the Forgejo commit status."""
|
|
||||||
sc, text, err = _forgejo_request("GET", f"/repos/{repo}/commits/{sha}/status")
|
|
||||||
if sc != 200:
|
|
||||||
return 1, "", err or f"commit status HTTP {sc}"
|
|
||||||
combined = json.loads(text)
|
|
||||||
rollup: list[dict] = []
|
|
||||||
for s in combined.get("statuses", []):
|
|
||||||
st = s.get("status", "").lower()
|
|
||||||
if st == "success":
|
|
||||||
rollup.append({"status": "COMPLETED", "conclusion": "SUCCESS"})
|
|
||||||
elif st == "pending":
|
|
||||||
rollup.append({"status": "IN_PROGRESS", "conclusion": ""})
|
|
||||||
elif st in ("failure", "error"):
|
|
||||||
rollup.append({"status": "COMPLETED", "conclusion": "FAILURE"})
|
|
||||||
else:
|
|
||||||
rollup.append({"status": "QUEUED", "conclusion": ""})
|
|
||||||
return 0, json.dumps({"statusCheckRollup": rollup}), ""
|
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_pr_view(repo: str, n: str, fields: str) -> tuple[int, str, str]: # noqa: PLR0911
|
|
||||||
"""Translate ``gh pr view N --json <fields>`` to Forgejo API calls."""
|
|
||||||
rc, pr_text, err = _forgejo_request("GET", f"/repos/{repo}/pulls/{n}")
|
|
||||||
if rc != 200:
|
|
||||||
return 1, "", err or f"PR API HTTP {rc}"
|
|
||||||
pr = json.loads(pr_text)
|
|
||||||
if fields == "statusCheckRollup":
|
|
||||||
return _forgejo_ci_rollup(repo, pr["head"]["sha"])
|
|
||||||
if fields == "comments":
|
|
||||||
sc, comments_text, ce = _forgejo_request("GET", f"/repos/{repo}/issues/{n}/comments")
|
|
||||||
if sc != 200:
|
|
||||||
return 1, "", ce or f"comments HTTP {sc}"
|
|
||||||
comments = json.loads(comments_text)
|
|
||||||
return 0, json.dumps({"comments": [{"body": c.get("body", "")} for c in comments]}), ""
|
|
||||||
if fields == "state":
|
|
||||||
state = "MERGED" if pr.get("merged") else pr.get("state", "").upper()
|
|
||||||
return 0, json.dumps({"state": state}), ""
|
|
||||||
if fields:
|
|
||||||
return 0, json.dumps({fields: pr.get(fields, "")}), ""
|
|
||||||
return 0, pr_text, ""
|
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_pr_dispatch(repo: str, args: list[str]) -> tuple[int, str, str]:
|
|
||||||
"""Translate ``gh pr ...`` argv to Forgejo API calls."""
|
|
||||||
action = args[1] if len(args) > 1 else ""
|
|
||||||
num = args[2] if len(args) > 2 and args[2].lstrip("-").isdigit() else None
|
|
||||||
if action == "view" and num is not None:
|
|
||||||
fields_idx = args.index("--json") if "--json" in args else -1
|
|
||||||
fields = args[fields_idx + 1] if fields_idx >= 0 else ""
|
|
||||||
return _forgejo_pr_view(repo, num, fields)
|
|
||||||
if action == "list":
|
|
||||||
sc, out_text, e = _forgejo_request("GET", f"/repos/{repo}/pulls?state=open")
|
|
||||||
if sc != 200:
|
|
||||||
return 1, "", e or f"pr list HTTP {sc}"
|
|
||||||
pulls = json.loads(out_text)
|
|
||||||
return 0, json.dumps([{"number": p["number"]} for p in pulls]), ""
|
|
||||||
return 1, "", f"gh pr argv {args!r} not supported in Forgejo mode"
|
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_gh_dispatch(args: list[str]) -> tuple[int, str, str] | None: # noqa: PLR0911
|
|
||||||
"""Translate a ``gh`` argv to a Forgejo API call. Returns None to defer.
|
|
||||||
|
|
||||||
Returns ``(rc, stdout, stderr)`` shaped like ``run_cmd`` so callers stay
|
|
||||||
unchanged. Returns ``None`` if ``FORGEJO_URL`` is unset (defer to gh) or
|
|
||||||
the argv is not a supported gh subcommand.
|
|
||||||
"""
|
|
||||||
if not os.environ.get("FORGEJO_URL"):
|
|
||||||
return None
|
|
||||||
if not os.environ.get("FORGEJO_TOKEN"):
|
|
||||||
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
|
|
||||||
if not args or args[0] != "gh":
|
|
||||||
return None
|
|
||||||
repo_idx = args.index("--repo") if "--repo" in args else -1
|
|
||||||
repo = args[repo_idx + 1] if repo_idx >= 0 else None
|
|
||||||
if repo is None:
|
|
||||||
return 1, "", "Forgejo mode requires --repo owner/name"
|
|
||||||
sub = args[1] if len(args) > 1 else ""
|
|
||||||
if sub == "auth" and "status" in args:
|
|
||||||
return 0, "", ""
|
|
||||||
if sub == "pr":
|
|
||||||
return _forgejo_pr_dispatch(repo, args)
|
|
||||||
if sub == "issue" and len(args) > 2 and args[2].lstrip("-").isdigit():
|
|
||||||
sc, out_text, e = _forgejo_request("GET", f"/repos/{repo}/issues/{args[2]}")
|
|
||||||
if sc == 200:
|
|
||||||
return 0, out_text, ""
|
|
||||||
return 1, "", e or f"issue HTTP {sc}"
|
|
||||||
return 1, "", f"gh argv {args!r} not supported in Forgejo mode"
|
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
||||||
"""Run a command, return (returncode, stdout, stderr).
|
"""Run a command, return (returncode, stdout, stderr)."""
|
||||||
|
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||||
Forgejo dispatch (ADR-forgejo): when ``FORGEJO_URL`` is set, ``gh`` argv
|
return result.returncode, result.stdout, result.stderr
|
||||||
is translated to a Forgejo REST API call instead of spawning ``gh``.
|
|
||||||
GitHub users (no ``FORGEJO_URL``) see byte-identical behaviour — the
|
|
||||||
``gh`` / ``git`` subprocess path is untouched.
|
|
||||||
"""
|
|
||||||
if args and args[0] == "gh":
|
|
||||||
result = _forgejo_gh_dispatch(args)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
proc = subprocess.run(args, capture_output=True, text=True, check=False)
|
|
||||||
return proc.returncode, proc.stdout, proc.stderr
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,10 @@ import argparse
|
||||||
import ast
|
import ast
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -190,50 +187,8 @@ class RepoCtx:
|
||||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_get(path: str) -> tuple[int, str, str]:
|
|
||||||
"""Forgejo REST GET. Returns (status_code, body_text, error)."""
|
|
||||||
base = os.environ.get("FORGEJO_URL")
|
|
||||||
token = os.environ.get("FORGEJO_TOKEN")
|
|
||||||
req = urllib.request.Request( # noqa: S310 - operator-configured base URL
|
|
||||||
f"{base}/api/v1{path}",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as r: # noqa: S310 - operator-configured base URL
|
|
||||||
return r.status, r.read().decode("utf-8", "replace"), ""
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
return e.code, e.read().decode("utf-8", "replace"), ""
|
|
||||||
except OSError as e:
|
|
||||||
return 0, "", str(e)
|
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
||||||
"""Run a command, return (returncode, stdout, stderr). Read-only intent.
|
"""Run a command, return (returncode, stdout, stderr). Read-only intent."""
|
||||||
|
|
||||||
Forgejo dispatch: when ``FORGEJO_URL`` is set, ``gh api
|
|
||||||
repos/<repo>/rules/branches/main`` is routed to the Forgejo
|
|
||||||
``branch_protections/main`` endpoint (different schema, but the
|
|
||||||
downstream string-matching checks ``"pull_request"`` /
|
|
||||||
``"required_status_checks"`` which Forgejo's BranchProtection fields
|
|
||||||
contain). GitHub users (no ``FORGEJO_URL``) see byte-identical behaviour.
|
|
||||||
"""
|
|
||||||
if (
|
|
||||||
args
|
|
||||||
and args[0] == "gh"
|
|
||||||
and os.environ.get("FORGEJO_URL")
|
|
||||||
and len(args) >= 2
|
|
||||||
and args[1] == "api"
|
|
||||||
):
|
|
||||||
if not os.environ.get("FORGEJO_TOKEN"):
|
|
||||||
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
|
|
||||||
m = re.match(r"repos/([^/]+/[^/]+)/rules/branches/(\S+)", " ".join(args[2:]))
|
|
||||||
if m:
|
|
||||||
repo, branch = m.group(1), m.group(2)
|
|
||||||
sc, out_text, err = _forgejo_get(f"/repos/{repo}/branch_protections/{branch}")
|
|
||||||
if sc == 200:
|
|
||||||
return 0, out_text, ""
|
|
||||||
return 1, "", err or f"branch protection HTTP {sc}"
|
|
||||||
return 1, "", f"gh api argv {args!r} not supported in Forgejo mode"
|
|
||||||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ STACK_REQUIRED: dict[str, list[str]] = {
|
||||||
"tailwind",
|
"tailwind",
|
||||||
"shadcn",
|
"shadcn",
|
||||||
"typescript",
|
"typescript",
|
||||||
"mobile-first",
|
|
||||||
],
|
],
|
||||||
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
|
"mcp-server": ["fastapi", "mcp", "patchright", "uv"],
|
||||||
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"],
|
"cli": ["typer", "uv", "hatchling", "ruff", "mypy", "pytest"],
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,9 @@ from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -114,47 +111,8 @@ class PhaseResult:
|
||||||
detail: str
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_get(path: str) -> tuple[int, str, str]:
|
|
||||||
"""Forgejo REST GET. Returns (status_code, body_text, error)."""
|
|
||||||
base = os.environ.get("FORGEJO_URL")
|
|
||||||
token = os.environ.get("FORGEJO_TOKEN")
|
|
||||||
req = urllib.request.Request( # noqa: S310 - operator-configured base URL
|
|
||||||
f"{base}/api/v1{path}",
|
|
||||||
headers={"Authorization": f"token {token}", "Accept": "application/json"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as r: # noqa: S310 - operator-configured base URL
|
|
||||||
return r.status, r.read().decode("utf-8", "replace"), ""
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
return e.code, e.read().decode("utf-8", "replace"), ""
|
|
||||||
except OSError as e:
|
|
||||||
return 0, "", str(e)
|
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
||||||
"""Run a command, return (returncode, stdout, stderr).
|
"""Run a command, return (returncode, stdout, stderr)."""
|
||||||
|
|
||||||
Forgejo dispatch: when ``FORGEJO_URL`` is set, ``gh issue view`` is routed
|
|
||||||
to the Forgejo REST API instead of spawning ``gh``. GitHub users (no
|
|
||||||
``FORGEJO_URL``) see byte-identical behaviour — the gh/git subprocess path
|
|
||||||
is untouched.
|
|
||||||
"""
|
|
||||||
if args and args[0] == "gh" and os.environ.get("FORGEJO_URL"):
|
|
||||||
if not os.environ.get("FORGEJO_TOKEN"):
|
|
||||||
return 1, "", "Forgejo mode requires FORGEJO_TOKEN"
|
|
||||||
repo_idx = args.index("--repo") if "--repo" in args else -1
|
|
||||||
repo = args[repo_idx + 1] if repo_idx >= 0 else None
|
|
||||||
if (
|
|
||||||
args[1:3] == ["issue", "view"]
|
|
||||||
and repo
|
|
||||||
and len(args) > 2
|
|
||||||
and args[2].lstrip("-").isdigit()
|
|
||||||
):
|
|
||||||
sc, out_text, err = _forgejo_get(f"/repos/{repo}/issues/{args[2]}")
|
|
||||||
if sc == 200:
|
|
||||||
return 0, out_text, ""
|
|
||||||
return 1, "", err or f"issue HTTP {sc}"
|
|
||||||
return 1, "", f"gh argv {args!r} not supported in Forgejo mode"
|
|
||||||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,6 @@ REVIEW → MERGE).
|
||||||
- service-слой пропущен (routes → db/models без services/)
|
- service-слой пропущен (routes → db/models без services/)
|
||||||
- файлы длиннее 200-300 строк (декомпозиция)
|
- файлы длиннее 200-300 строк (декомпозиция)
|
||||||
- mixing concerns (бизнес-логика ≠ транспорт ≠ представление)
|
- mixing concerns (бизнес-логика ≠ транспорт ≠ представление)
|
||||||
- mobile-first missing (fullstack): нет PWA manifest, нет Playwright mobile spec, нет axe a11y spec, нет viewport meta — `STACK_REQUIRED["fullstack"]` требует "mobile-first", но качественно проверь что mobile-first реален, а не просто слово в stack.md
|
|
||||||
|
|
||||||
Для каждой находки верни:
|
Для каждой находки верни:
|
||||||
{category: "Code-standards", problem: "<name>: <detail>", path: "<file:line>", severity: "warn"|"fail"}
|
{category: "Code-standards", problem: "<name>: <detail>", path: "<file:line>", severity: "warn"|"fail"}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ src/<package>/
|
||||||
|
|
||||||
### Fullstack (кратко)
|
### Fullstack (кратко)
|
||||||
|
|
||||||
Backend as above (in `backend/` + `frontend/` separation). Frontend: SvelteKit co-located `*.test.ts` в `src/lib/`, `e2e/*.spec.ts` для Playwright. НЕ смешивать backend код в `frontend/` и наоборот. + mobile-first (PWA + Playwright mobile + axe a11y) — silent enforcement через `STACK_REQUIRED["fullstack"]`.
|
Backend as above (in `backend/` + `frontend/` separation). Frontend: SvelteKit co-located `*.test.ts` в `src/lib/`, `e2e/*.spec.ts` для Playwright. НЕ смешивать backend код в `frontend/` и наоборот.
|
||||||
|
|
||||||
### CLI (кратко)
|
### CLI (кратко)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ truth для порядка и действий — `spec-status` tool. На в
|
||||||
Общий для всех типов: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI.
|
Общий для всех типов: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI.
|
||||||
|
|
||||||
- **backend**: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich — legacy), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
- **backend**: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich — legacy), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
||||||
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
|
- **fullstack**: backend + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip)
|
||||||
- **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
- **mcp-server**: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
||||||
- **cli**: Typer (default) / click / argparse, hatchling build
|
- **cli**: Typer (default) / click / argparse, hatchling build
|
||||||
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
- **bot**: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
||||||
|
|
@ -88,7 +88,7 @@ backend:
|
||||||
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
|
- Auth: [1] none v1 / [2] JWT / [3] X-API-Key
|
||||||
|
|
||||||
fullstack:
|
fullstack:
|
||||||
- frontend: [1] SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte (default, mobile-first: PWA + axe + Playwright mobile — silent) / [2] add later
|
- frontend: [1] SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte (default) / [2] add later
|
||||||
- DB: (same as backend)
|
- DB: (same as backend)
|
||||||
- Auth: (same as backend)
|
- Auth: (same as backend)
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ Spec complete. Issues: #N1, #N2, ...
|
||||||
Default stack для типа (хардкод, добавить всегда):
|
Default stack для типа (хардкод, добавить всегда):
|
||||||
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI
|
- Общий: Python 3.12+, uv, hatchling, ruff, mypy strict, pytest 90% cov, xenon, pre-commit, .editorconfig, .gitignore, LICENSE MIT, dependabot, CI
|
||||||
- backend: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
- backend: FastAPI + uvicorn, Tortoise ORM (встроенные миграции `tortoise makemigrations`, НЕ Aerich), Pydantic v2 + pydantic-settings, Loguru, опц. JWT-auth (`passlib[bcrypt]` + `pyjwt`)
|
||||||
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip + mobile-first (PWA + axe + Playwright mobile))
|
- fullstack: + frontend/ (SvelteKit + Svelte 5 runes (TS) + Tailwind v4 + shadcn-svelte + Biome + Vitest + Knip)
|
||||||
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
- mcp-server: FastAPI + MCP SDK, Patchright/Playwright over CDP, X-API-Key
|
||||||
- cli: Typer (default) / click / argparse, hatchling build
|
- cli: Typer (default) / click / argparse, hatchling build
|
||||||
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
- bot: aiogram 3.x, FastAPI webhook/polling, Tortoise (опц.), Pydantic AI (опц.)
|
||||||
|
|
|
||||||
|
|
@ -46,21 +46,9 @@ jobs:
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm test
|
- run: npm test
|
||||||
|
|
||||||
frontend-e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults: { run: { working-directory: frontend } }
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: '20' }
|
|
||||||
- run: npm install
|
|
||||||
- run: npx playwright install --with-deps
|
|
||||||
- run: npm run build
|
|
||||||
- run: npm run test:e2e
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [backend-lint, backend-typecheck, backend-test, frontend-test, frontend-e2e]
|
needs: [backend-lint, backend-typecheck, backend-test, frontend-test]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: echo "All checks passed"
|
- run: echo "All checks passed"
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
|
|
||||||
type GhResult = { status: number | null; stdout: string; stderr: string }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the `--repo <owner/repo>` argv fragment for `gh`.
|
* Build the `--repo <owner/repo>` argv fragment for `gh`.
|
||||||
*
|
*
|
||||||
|
|
@ -16,167 +14,18 @@ export function parseRepo(repo?: string): string[] {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve `owner/repo` for the Forgejo API path.
|
|
||||||
*
|
|
||||||
* `repo` is the explicit owner/name (the same string gh would receive via
|
|
||||||
* `--repo`). When `repo` is omitted, infer it from the `origin` git remote
|
|
||||||
* of the worktree at `opts.cwd` — this mirrors how `gh` auto-detects the
|
|
||||||
* repo from cwd in GitHub mode. Returns `owner/name` or null if the remote
|
|
||||||
* can't be parsed (caller surfaces an error).
|
|
||||||
*/
|
|
||||||
function resolveForgejoRepo(repo: string | undefined, opts?: { cwd?: string }): string | null {
|
|
||||||
if (repo) return repo
|
|
||||||
const cwd = opts?.cwd
|
|
||||||
if (!cwd) return null
|
|
||||||
const r = spawnSync("git", ["-C", cwd, "config", "--get", "remote.origin.url"], {
|
|
||||||
encoding: "utf-8",
|
|
||||||
})
|
|
||||||
if (r.status !== 0) return null
|
|
||||||
const url = r.stdout.trim()
|
|
||||||
const m = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
|
|
||||||
return m ? `${m[1]}/${m[2]}` : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forgejo REST API helper. Returns a spawnSync-shaped result so the caller's
|
|
||||||
* `formatResult` / status-check code works unchanged. `okStatus` is the HTTP
|
|
||||||
* status treated as success (200 for GET/POST-create, 204 for merge). Non-2xx
|
|
||||||
* is reported as a non-zero `status` with the response body in `stderr`.
|
|
||||||
*/
|
|
||||||
async function callForgejo(
|
|
||||||
method: string,
|
|
||||||
path: string,
|
|
||||||
body: unknown,
|
|
||||||
opts: { okStatus?: number; cwd?: string },
|
|
||||||
): Promise<GhResult> {
|
|
||||||
const base = process.env.FORGEJO_URL
|
|
||||||
const token = process.env.FORGEJO_TOKEN
|
|
||||||
const okStatus = opts.okStatus ?? 200
|
|
||||||
const init: RequestInit = {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${token}`,
|
|
||||||
Accept: "application/json",
|
|
||||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (body !== undefined) init.body = JSON.stringify(body)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${base}/api/v1${path}`, init)
|
|
||||||
const text = await res.text()
|
|
||||||
if (res.status === okStatus || (okStatus === 200 && res.status >= 200 && res.status < 300)) {
|
|
||||||
return { status: 0, stdout: text, stderr: "" }
|
|
||||||
}
|
|
||||||
return { status: 1, stdout: "", stderr: `Forgejo API ${method} ${path} → HTTP ${res.status}: ${text}` }
|
|
||||||
} catch (e) {
|
|
||||||
return { status: 1, stdout: "", stderr: `Forgejo API ${method} ${path} failed: ${String(e)}` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run `gh` with the given subcommand args, optionally targeting an explicit
|
* Run `gh` with the given subcommand args, optionally targeting an explicit
|
||||||
* repo. When `repo` is omitted, `gh` auto-detects the repo from `opts.cwd`
|
* repo. When `repo` is omitted, `gh` auto-detects the repo from `opts.cwd`
|
||||||
* (callers pass `context.worktree`). Returns the raw spawnSync result so the
|
* (callers pass `context.worktree`). Returns the raw spawnSync result so the
|
||||||
* caller can inspect `status`/`stdout`/`stderr` directly, or pass it to
|
* caller can inspect `status`/`stdout`/`stderr` directly, or pass it to
|
||||||
* `formatResult` for the standard error string.
|
* `formatResult` for the standard error string.
|
||||||
*
|
|
||||||
* Dispatch (ADR-forgejo): when `FORGEJO_URL` is set, route to the Forgejo REST
|
|
||||||
* API via `callForgejo` (translating the gh argv to the equivalent API call)
|
|
||||||
* instead of spawning `gh`. GitHub users (no `FORGEJO_URL`) see byte-identical
|
|
||||||
* behaviour — the `gh` path is untouched. The Forgejo result is shaped like a
|
|
||||||
* spawnSync result (`{status, stdout, stderr}`) so callers don't branch.
|
|
||||||
*/
|
*/
|
||||||
export async function runGh(args: string[], repo?: string, opts?: { cwd?: string }): Promise<GhResult> {
|
export function runGh(args: string[], repo?: string, opts?: { cwd?: string }) {
|
||||||
if (process.env.FORGEJO_URL) {
|
|
||||||
return callForgejoGh(args, repo, opts)
|
|
||||||
}
|
|
||||||
const fullArgs = [...parseRepo(repo), ...args]
|
const fullArgs = [...parseRepo(repo), ...args]
|
||||||
return spawnSync("gh", fullArgs, { encoding: "utf-8", cwd: opts?.cwd })
|
return spawnSync("gh", fullArgs, { encoding: "utf-8", cwd: opts?.cwd })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Translate the supported gh argv shapes to Forgejo API calls. Only the
|
|
||||||
* commands used by the 4 GitHub tools are dispatched (pr merge, pr create,
|
|
||||||
* issue create, pr comment); any other argv falls back to a non-zero
|
|
||||||
* "unsupported in Forgejo mode" error so the dispatch is explicit.
|
|
||||||
*/
|
|
||||||
async function callForgejoGh(
|
|
||||||
args: string[],
|
|
||||||
repo: string | undefined,
|
|
||||||
opts?: { cwd?: string },
|
|
||||||
): Promise<GhResult> {
|
|
||||||
const full = repo ?? resolveForgejoRepo(repo, opts)
|
|
||||||
if (!full) {
|
|
||||||
return {
|
|
||||||
status: 1,
|
|
||||||
stdout: "",
|
|
||||||
stderr: "Forgejo mode requires owner/repo — none provided and origin remote not parseable",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (args[0] === "pr" && args[1] === "merge") {
|
|
||||||
const n = args[2]
|
|
||||||
return callForgejo(
|
|
||||||
"POST",
|
|
||||||
`/repos/${full}/pulls/${n}/merge`,
|
|
||||||
{ Do: "squash", delete_branch_after_merge: true },
|
|
||||||
{ okStatus: 200, cwd: opts?.cwd },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (args[0] === "pr" && args[1] === "create") {
|
|
||||||
const titleIdx = args.indexOf("--title")
|
|
||||||
const bodyIdx = args.indexOf("--body")
|
|
||||||
const title = titleIdx >= 0 ? args[titleIdx + 1] : ""
|
|
||||||
const body = bodyIdx >= 0 ? args[bodyIdx + 1] : ""
|
|
||||||
const headIdx = args.indexOf("--head")
|
|
||||||
const baseIdx = args.indexOf("--base")
|
|
||||||
const head = headIdx >= 0 ? args[headIdx + 1] : undefined
|
|
||||||
const base = baseIdx >= 0 ? args[baseIdx + 1] : undefined
|
|
||||||
const r = await callForgejo(
|
|
||||||
"POST",
|
|
||||||
`/repos/${full}/pulls`,
|
|
||||||
{ title, body, ...(head ? { head } : {}), ...(base ? { base } : {}) },
|
|
||||||
{ cwd: opts?.cwd },
|
|
||||||
)
|
|
||||||
if (r.status !== 0) return r
|
|
||||||
const pr = JSON.parse(r.stdout)
|
|
||||||
return { status: 0, stdout: pr.html_url + "\n", stderr: "" }
|
|
||||||
}
|
|
||||||
if (args[0] === "pr" && args[1] === "comment") {
|
|
||||||
const n = args[2]
|
|
||||||
const bodyIdx = args.indexOf("--body")
|
|
||||||
const body = bodyIdx >= 0 ? args[bodyIdx + 1] : ""
|
|
||||||
return callForgejo(
|
|
||||||
"POST",
|
|
||||||
`/repos/${full}/issues/${n}/comments`,
|
|
||||||
{ body },
|
|
||||||
{ okStatus: 201, cwd: opts?.cwd },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (args[0] === "issue" && args[1] === "create") {
|
|
||||||
const titleIdx = args.indexOf("--title")
|
|
||||||
const bodyIdx = args.indexOf("--body")
|
|
||||||
const labelIdx = args.indexOf("--label")
|
|
||||||
const title = titleIdx >= 0 ? args[titleIdx + 1] : ""
|
|
||||||
const body = bodyIdx >= 0 ? args[bodyIdx + 1] : ""
|
|
||||||
const labels = labelIdx >= 0 ? args[labelIdx + 1].split(",") : []
|
|
||||||
const r = await callForgejo(
|
|
||||||
"POST",
|
|
||||||
`/repos/${full}/issues`,
|
|
||||||
{ title, body, labels },
|
|
||||||
{ okStatus: 201, cwd: opts?.cwd },
|
|
||||||
)
|
|
||||||
if (r.status !== 0) return r
|
|
||||||
const issue = JSON.parse(r.stdout)
|
|
||||||
return { status: 0, stdout: issue.html_url + "\n", stderr: "" }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: 1,
|
|
||||||
stdout: "",
|
|
||||||
stderr: `gh argv ${JSON.stringify(args)} not supported in Forgejo mode`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard success/error formatter for GitHub tools.
|
* Standard success/error formatter for GitHub tools.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export default tool({
|
||||||
ghArgs.push("--label", args.labels.join(","))
|
ghArgs.push("--label", args.labels.join(","))
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = await runGh(ghArgs, args.repo, { cwd: context.worktree })
|
const r = runGh(ghArgs, args.repo, { cwd: context.worktree })
|
||||||
if (r.status !== 0) {
|
if (r.status !== 0) {
|
||||||
return formatResult(r, "gh issue create")
|
return formatResult(r, "gh issue create")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export default tool({
|
||||||
body = body + "\n\nCloses #" + args.issue_number
|
body = body + "\n\nCloses #" + args.issue_number
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = await runGh(["pr", "create", "--title", title, "--body", body], args.repo, { cwd: context.worktree })
|
const r = runGh(["pr", "create", "--title", title, "--body", body], args.repo, { cwd: context.worktree })
|
||||||
if (r.status !== 0) {
|
if (r.status !== 0) {
|
||||||
return formatResult(r, "gh pr create")
|
return formatResult(r, "gh pr create")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -296,46 +296,6 @@ export default tool({
|
||||||
})
|
})
|
||||||
|
|
||||||
if (args.repo) {
|
if (args.repo) {
|
||||||
const content64 = Buffer.from(content, "utf-8").toString("base64")
|
|
||||||
if (process.env.FORGEJO_URL) {
|
|
||||||
const base = process.env.FORGEJO_URL
|
|
||||||
const token = process.env.FORGEJO_TOKEN
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
Authorization: `token ${token}`,
|
|
||||||
Accept: "application/json",
|
|
||||||
}
|
|
||||||
let sha: string | undefined
|
|
||||||
try {
|
|
||||||
const getRes = await fetch(`${base}/api/v1/repos/${args.repo}/contents/README.md`, { headers })
|
|
||||||
if (getRes.status === 200) {
|
|
||||||
sha = (await getRes.json()).sha
|
|
||||||
} else if (getRes.status !== 404) {
|
|
||||||
const text = await getRes.text()
|
|
||||||
return `⚠️ create-readme failed: Forgejo GET README.md → HTTP ${getRes.status}: ${text}`
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return `⚠️ create-readme failed: Forgejo GET failed: ${e instanceof Error ? e.message : String(e)}`
|
|
||||||
}
|
|
||||||
const putBody: Record<string, string> = {
|
|
||||||
content: content64,
|
|
||||||
message: "docs: update README",
|
|
||||||
}
|
|
||||||
if (sha) putBody.sha = sha
|
|
||||||
try {
|
|
||||||
const putRes = await fetch(`${base}/api/v1/repos/${args.repo}/contents/README.md`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { ...headers, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(putBody),
|
|
||||||
})
|
|
||||||
if (!putRes.ok) {
|
|
||||||
const text = await putRes.text()
|
|
||||||
return `⚠️ create-readme failed: Forgejo PUT README.md → HTTP ${putRes.status}: ${text}`
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return `⚠️ create-readme failed: Forgejo PUT failed: ${e instanceof Error ? e.message : String(e)}`
|
|
||||||
}
|
|
||||||
return `README.md updated in ${args.repo} via Forgejo API`
|
|
||||||
}
|
|
||||||
const getRes = spawnSync(
|
const getRes = spawnSync(
|
||||||
"gh",
|
"gh",
|
||||||
["api", `repos/${args.repo}/contents/README.md`],
|
["api", `repos/${args.repo}/contents/README.md`],
|
||||||
|
|
@ -349,6 +309,7 @@ export default tool({
|
||||||
sha = undefined
|
sha = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const content64 = Buffer.from(content, "utf-8").toString("base64")
|
||||||
const putArgs = [
|
const putArgs = [
|
||||||
"api",
|
"api",
|
||||||
"-X",
|
"-X",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export default tool({
|
||||||
repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."),
|
repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
const r = await runGh(
|
const r = runGh(
|
||||||
["pr", "merge", String(args.pr_number), "--squash", "--delete-branch"],
|
["pr", "merge", String(args.pr_number), "--squash", "--delete-branch"],
|
||||||
args.repo,
|
args.repo,
|
||||||
{ cwd: context.worktree },
|
{ cwd: context.worktree },
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default tool({
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
const comment = `## Code Review Summary\n\n${args.body}\n\n### Verdict: ${args.verdict}`
|
const comment = `## Code Review Summary\n\n${args.body}\n\n### Verdict: ${args.verdict}`
|
||||||
const r = await runGh(["pr", "comment", String(args.pr_number), "--body", comment], args.repo, { cwd: context.worktree })
|
const r = runGh(["pr", "comment", String(args.pr_number), "--body", comment], args.repo, { cwd: context.worktree })
|
||||||
if (r.status !== 0) {
|
if (r.status !== 0) {
|
||||||
return `⚠️ post-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}`
|
return `⚠️ post-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
"Project status oracle. Read-only check of repo architecture conformance. Auto-detects project type (frontend→fullstack, fastapi→backend, typer→cli, aiogram→bot, prefect→worker) and runs 8 check groups: Структура (incl. mobile-first PWA + Playwright mobile + axe a11y for fullstack), Тонкие роуты (AST ≤50 lines), Качество кода (mypy/ruff/pytest), Тесты (conftest, stub-detector, no @pytest.mark.asyncio), README (12 delimiter tags), Infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit), Coverage (non-blocking), Pyproject (13 checks: build-system, hatch wheel, project fields, ruff/mypy/pytest config, coverage, pre-commit, uv.lock, requires-python vs .python-version). Issue #275: all checks are non-blocking (WARN) and the exit code is always 0 (informational mode); check=true is accepted for CLI compatibility but no longer forces exit 1; pass fast=true to skip slow/remote checks (branch protection); pass repo=<path> to check an arbitrary repo instead of the current worktree.",
|
"Project status oracle. Read-only check of repo architecture conformance. Auto-detects project type (frontend→fullstack, fastapi→backend, typer→cli, aiogram→bot, prefect→worker) and runs 8 check groups: Структура, Тонкие роуты (AST ≤50 lines), Качество кода (mypy/ruff/pytest), Тесты (conftest, stub-detector, no @pytest.mark.asyncio), README (12 delimiter tags), Infra (branch protection, ci.yml, dependabot, LICENSE, pre-commit), Coverage (non-blocking), Pyproject (13 checks: build-system, hatch wheel, project fields, ruff/mypy/pytest config, coverage, pre-commit, uv.lock, requires-python vs .python-version). Issue #275: all checks are non-blocking (WARN) and the exit code is always 0 (informational mode); check=true is accepted for CLI compatibility but no longer forces exit 1; pass fast=true to skip slow/remote checks (branch protection); pass repo=<path> to check an arbitrary repo instead of the current worktree.",
|
||||||
args: {
|
args: {
|
||||||
check: tool.schema.boolean().optional().describe("Accepted for CLI compatibility — issue #275: exit code is always 0 (all checks WARN, non-blocking)"),
|
check: tool.schema.boolean().optional().describe("Accepted for CLI compatibility — issue #275: exit code is always 0 (all checks WARN, non-blocking)"),
|
||||||
fast: tool.schema.boolean().optional().describe("If true, skip slow/remote checks (branch protection via gh)"),
|
fast: tool.schema.boolean().optional().describe("If true, skip slow/remote checks (branch protection via gh)"),
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,7 @@ function stripTs(src) {
|
||||||
out = out.replace(/^type\s+\w+\s*=\s*\{[^}]*\}\s*;?\s*$/gms, "")
|
out = out.replace(/^type\s+\w+\s*=\s*\{[^}]*\}\s*;?\s*$/gms, "")
|
||||||
out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "")
|
out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "")
|
||||||
// Strip `export ` keyword on top-level declarations (shared modules).
|
// Strip `export ` keyword on top-level declarations (shared modules).
|
||||||
// Supports `export async function` (async helpers added for Forgejo dispatch).
|
out = out.replace(/^export\s+(function|const|let|var)\b/gm, "$1")
|
||||||
out = out.replace(/^export\s+(async\s+)?(function|const|let|var)\b/gm, "$1$2")
|
|
||||||
// Strip type annotations on function params + return type:
|
// Strip type annotations on function params + return type:
|
||||||
// `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {`
|
// `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {`
|
||||||
// Handles single-line function signatures (used by _shared.ts). Object
|
// Handles single-line function signatures (used by _shared.ts). Object
|
||||||
|
|
@ -117,7 +116,7 @@ function stripTs(src) {
|
||||||
// Return type may itself be an object literal type (e.g. create-readme.ts
|
// Return type may itself be an object literal type (e.g. create-readme.ts
|
||||||
// `validateReadme(content: string): { ok: boolean; issues: string[] }`),
|
// `validateReadme(content: string): { ok: boolean; issues: string[] }`),
|
||||||
// so match greedily from `):` up to the final ` {` that opens the body.
|
// so match greedily from `):` up to the final ` {` that opens the body.
|
||||||
out = out.replace(/^(\s*(?:async\s+)?function\s+\w+\s*\()([^)]*)\)(\s*:\s*.+?)?\s*\{/gm, (line, head, params, _ret) => {
|
out = out.replace(/^(\s*function\s+\w+\s*\()([^)]*)\)(\s*:\s*.+?)?\s*\{/gm, (line, head, params, _ret) => {
|
||||||
const cleaned = params
|
const cleaned = params
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim())
|
.map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim())
|
||||||
|
|
|
||||||
|
|
@ -402,30 +402,6 @@ def test_fullstack_uses_typescript(render):
|
||||||
assert "tw-animate" not in deps or "tw-animate-css" in deps
|
assert "tw-animate" not in deps or "tw-animate-css" in deps
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
|
|
||||||
def test_fullstack_ci_has_frontend_e2e_job(render):
|
|
||||||
"""Root ci.yml has a ``frontend-e2e`` job that installs deps without a
|
|
||||||
lockfile (``npm install``, NOT ``npm ci``), wires Playwright, and runs
|
|
||||||
in the ``frontend/`` working directory.
|
|
||||||
|
|
||||||
Regression guard for PR#281 review critical #1: cookiecutter templates
|
|
||||||
ship no ``package-lock.json``, so ``npm ci`` fails in fresh projects.
|
|
||||||
"""
|
|
||||||
ci = (render / ".github/workflows/ci.yml").read_text()
|
|
||||||
assert "frontend-e2e" in ci, "ci.yml must define a frontend-e2e job"
|
|
||||||
assert "npm install" in ci, "ci.yml must use npm install (no lockfile in template)"
|
|
||||||
assert "npm ci" not in ci, (
|
|
||||||
"ci.yml must NOT use npm ci (cookiecutter template has no package-lock.json)"
|
|
||||||
)
|
|
||||||
assert "playwright install --with-deps" in ci, (
|
|
||||||
"ci.yml must install Playwright browsers with --with-deps"
|
|
||||||
)
|
|
||||||
assert "test:e2e" in ci, "ci.yml must run the e2e suite (npm run test:e2e)"
|
|
||||||
assert "working-directory: frontend" in ci, (
|
|
||||||
"ci.yml must run frontend steps in the frontend/ working directory"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── pyproject.toml completeness ──────────────────────────────────────────────
|
# ── pyproject.toml completeness ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,10 @@ def _gh_available() -> bool:
|
||||||
["gh", "auth", "status"],
|
["gh", "auth", "status"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=False,
|
check=False,
|
||||||
timeout=10,
|
|
||||||
).returncode
|
).returncode
|
||||||
== 0
|
== 0
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except FileNotFoundError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,26 +30,6 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def _gh_available() -> bool:
|
|
||||||
"""True if `gh auth status` succeeds (local dev machine, not CI runner)."""
|
|
||||||
try:
|
|
||||||
return (
|
|
||||||
subprocess.run(
|
|
||||||
["gh", "auth", "status"],
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
timeout=10,
|
|
||||||
).returncode
|
|
||||||
== 0
|
|
||||||
)
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
_GH_OK = _gh_available()
|
|
||||||
_SKIP_REASON = "gh CLI not authenticated — skip real project-status.py call"
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
|
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
|
||||||
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "project-status.ts"
|
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "project-status.ts"
|
||||||
|
|
@ -170,7 +150,6 @@ def test_execute_uses_cwd_from_context():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _GH_OK, reason=_SKIP_REASON)
|
|
||||||
def test_execute_real_project_status():
|
def test_execute_real_project_status():
|
||||||
"""Integration: execute() returns the real project-status.py output (non-blocking)."""
|
"""Integration: execute() returns the real project-status.py output (non-blocking)."""
|
||||||
if not (REPO_ROOT / ".opencode" / "scripts" / "project-status.py").exists():
|
if not (REPO_ROOT / ".opencode" / "scripts" / "project-status.py").exists():
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,10 @@ def _gh_available() -> bool:
|
||||||
["gh", "auth", "status"],
|
["gh", "auth", "status"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=False,
|
check=False,
|
||||||
timeout=10,
|
|
||||||
).returncode
|
).returncode
|
||||||
== 0
|
== 0
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except FileNotFoundError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue