diff --git a/CLAUDE.md b/CLAUDE.md index 48bb5eb58..b33114919 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,13 @@ Communication between processes happens through shared files in `instance/` with - **`provider/__init__.py`** — Provider registry, resolution (env → config → default), cached singleton, and convenience functions (`run_command()`, `run_command_streaming()`, `build_full_command()`). Main entry point for the provider package. - **`cli_provider.py`** — Re-export facade (legacy); prefer importing from `provider` directly +**Forge abstraction** (`koan/app/forge/`): +- **`forge/base.py`** — `ForgeProvider` ABC + `FEATURE_*` flags. Unsupported operations raise `NotImplementedError`; callers check `supports()` or branch on `forge.name`. +- **`forge/github.py`** — `GitHubForge`, thin delegation wrapper over `app.github` (canonical `gh` implementation; zero behavior change). +- **`forge/gogs.py`** — `GogsForge` for self-hosted Gogs via REST API v1 (`urllib`); host/token from `KOAN_GOGS_HOST`/`KOAN_GOGS_TOKEN`. Supports PR + issue ops (no drafts/CI/reactions). +- **`forge/registry.py`** — maps `forge:` type string → provider class (`DEFAULT_FORGE = "github"`). +- **`forge/__init__.py`** — `get_forge(project_name)` resolves from `projects.yaml` `forge:`/`forge_url`/`github_url` (default GitHub); `get_forge_for_path(project_path)` resolves by directory basename. Loop-critical PR paths (`pr_submit`, `git_sync` merged detection, `branch_limiter` open-PR detection, `mission_verifier.check_pr_created`) route through the forge so non-GitHub projects function end-to-end; GitHub-only enrichments (`pr_feedback`, `deep_research`, `pr_quality`) degrade quietly on other forges. See `docs/architecture/github-and-trackers.md`. + **Git & GitHub:** - **`git_sync.py`** / **`git_auto_merge.py`** — Branch tracking, sync awareness, configurable auto-merge - **`github.py`** — Centralized `gh` CLI wrapper (`run_gh()`, `pr_create()`, `issue_create()`) diff --git a/docs/architecture/github-and-trackers.md b/docs/architecture/github-and-trackers.md index 61f23518e..9fbac1353 100644 --- a/docs/architecture/github-and-trackers.md +++ b/docs/architecture/github-and-trackers.md @@ -27,6 +27,56 @@ creation, review, rebasing, recreating, squashing, CI fixing, and PR quality checks. Auto-merge is configurable and should remain guarded by project config, security review, and sync state. +## Forge Abstraction + +Git-hosting platforms are abstracted behind `ForgeProvider` (`koan/app/forge/`), +mirroring the CLI-provider pattern. Each platform subclasses `ForgeProvider` and +implements the operations it supports; unsupported operations raise +`NotImplementedError` and callers check `supports()` (or branch on +`forge.name`) before using optional features. + +- `forge/base.py` — abstract base + `FEATURE_*` flags. +- `forge/github.py` — `GitHubForge`, a thin delegation wrapper over + `app.github` (the canonical `gh`-CLI implementation). Zero behavior change. +- `forge/gogs.py` — `GogsForge`, talking to the Gogs REST API v1 directly via + `urllib` (host/token from `KOAN_GOGS_HOST` / `KOAN_GOGS_TOKEN`). Gogs supports + PR and issue operations; it has no draft PRs, CI status, reactions, or rich + PR-review-comment API. +- `forge/registry.py` — maps the `forge:` type string to a provider class. +- `forge/__init__.py` — `get_forge(project_name)` resolves the provider from the + project's `forge:` field (or `forge_url` / `github_url` domain), defaulting to + GitHub. `get_forge_for_path(project_path)` is the convenience form for callers + that only have a checkout path (project name = directory basename). + +### Resolution + +`get_forge()` reads `projects..forge` from `projects.yaml` and falls back +to GitHub for any unconfigured or unknown project, so existing GitHub setups are +unaffected. A self-hosted Gogs project is declared with `forge: gogs` (and may +set `forge_url`); the bot reads `KOAN_GOGS_HOST` and `KOAN_GOGS_TOKEN` from the +environment for API access. + +### Caller wiring + +Loop-critical PR paths route through the forge so non-GitHub projects function +end-to-end instead of failing on `gh` and accumulating un-PR'd branches until +they hit branch-saturation: + +- PR creation, existing-PR detection, and fork/target resolution + (`pr_submit.py`). +- Merged-branch detection (`git_sync.get_github_merged_branches`) and open-PR + detection (`branch_limiter`) — both feed the saturation accounting, so a + non-GitHub forge can recognise merged/open work and free up branch budget. +- Post-mission PR verification (`mission_verifier.check_pr_created`). + +The GitHub code path in each of these is kept byte-for-byte identical (selected +when `forge.name == "github"`); the forge branch is taken only for other forges. + +GitHub-only enrichments with no forge-neutral equivalent — merge-velocity +analytics (`pr_feedback`), deep-research issue/PR fetching (`deep_research`), and +PR-body/comment mutation (`pr_quality`) — degrade quietly (skip) on non-GitHub +forges rather than erroring every iteration. + ## Trackers Tracker files in `instance/` prevent duplicate work across daemon iterations. diff --git a/env.example b/env.example index bbd9206a8..0e3360ee7 100644 --- a/env.example +++ b/env.example @@ -1,3 +1,7 @@ +# ========================================================================= +# GLOBAL CONFIGURATION +# ========================================================================= + # KOAN_ROOT — Path to the koan repository root (required) # This is auto-set by Makefile targets, but you can override it here. # Uncomment and set to your actual path: @@ -6,6 +10,9 @@ # Git identity for koan's commits (optional but recommended) # KOAN_EMAIL="koan@yourdomain.com" +# KOAN_BRIDGE_INTERVAL=3 # Chat poll interval in seconds (default: 3) +# Note: max_runs_per_day and interval_seconds are configured in config.yaml + # ========================================================================= # MESSAGING PROVIDER (optional — defaults to Telegram) # ========================================================================= @@ -76,6 +83,10 @@ # KOAN_CLI_PROVIDER=claude # Note: CLI_PROVIDER (without KOAN_ prefix) is also supported for backward compatibility +# ========================================================================= +# GITHUB CONFIGURATION (default mode of operation for finding repos) +# ========================================================================= + # GitHub CLI identity # If set, gh CLI commands will run as this user (via gh auth token --user). # The user must be pre-authenticated with: gh auth login --user @@ -89,6 +100,15 @@ # See docs/messaging/github-webhooks.md for the full setup (tunnel + webhook config). # KOAN_GITHUB_WEBHOOK_SECRET= -# KOAN_BRIDGE_INTERVAL=3 # Telegram poll interval in seconds (default: 3) +# ========================================================================= +# GOGS CONFIGURATION (optional — for self-hosted Gogs instances) +# ========================================================================= +# Set forge: gogs in projects.yaml for any project hosted on your Gogs server. +# The scripts/gogs CLI wrapper also reads these at runtime. + +# Base URL of your Gogs instance (no trailing slash) +# KOAN_GOGS_HOST=https://git.example.com + +# Personal access token — generate one at: https:///user/settings/applications +# KOAN_GOGS_TOKEN=your-token-here -# Note: max_runs_per_day and interval_seconds are configured in config.yaml diff --git a/koan/app/auto_update.py b/koan/app/auto_update.py index 4ed89ac9b..1cfe6a9da 100644 --- a/koan/app/auto_update.py +++ b/koan/app/auto_update.py @@ -95,9 +95,21 @@ def check_for_updates(koan_root: str) -> Optional[int]: log("update", f"Fetch failed: {result.stderr.strip()}") return None - # Compare local main vs remote main + # Compare what we have against the remote's default branch. + # + # The base ref must actually exist locally: this checkout may be on a + # development branch (e.g. trogbot_live_code) with no local `main`, in + # which case `main../main` raises "ambiguous argument" and used + # to spam the log every cycle. Prefer a local default branch when present, + # otherwise fall back to HEAD so the comparison always resolves. + base_ref = _resolve_base_ref(koan_path) + remote_ref = _resolve_remote_default_ref(koan_path, remote) + if remote_ref is None: + log("update", f"No {remote}/main or {remote}/master ref — skipping update check") + return None + result = _run_git( - ["rev-list", "--count", f"main..{remote}/main"], + ["rev-list", "--count", f"{base_ref}..{remote_ref}"], koan_path, ) if result.returncode != 0: @@ -110,6 +122,34 @@ def check_for_updates(koan_root: str) -> Optional[int]: return None +def _ref_exists(koan_path: Path, ref: str) -> bool: + """Return True if the given git ref resolves in the repo.""" + result = _run_git(["rev-parse", "--verify", "--quiet", ref], koan_path) + return result.returncode == 0 + + +def _resolve_base_ref(koan_path: Path) -> str: + """Return a local ref to compare against the remote default branch. + + Prefers a local ``main`` / ``master`` branch when one exists, otherwise + falls back to ``HEAD`` so the comparison resolves even on a checkout + (e.g. a development branch) that has no local default branch. + """ + for ref in ("refs/heads/main", "refs/heads/master"): + if _ref_exists(koan_path, ref): + return ref + return "HEAD" + + +def _resolve_remote_default_ref(koan_path: Path, remote: str) -> Optional[str]: + """Return the remote's default-branch tracking ref, or None if absent.""" + for branch in ("main", "master"): + ref = f"{remote}/{branch}" + if _ref_exists(koan_path, ref): + return ref + return None + + def _get_latest_tag(koan_path: Path) -> Optional[str]: """Get the latest tag by version sort order. diff --git a/koan/app/awake.py b/koan/app/awake.py index c38f1c74c..0119ebb2e 100755 --- a/koan/app/awake.py +++ b/koan/app/awake.py @@ -83,7 +83,12 @@ def _get_last_message_id() -> int: def check_config(): - if not BOT_TOKEN or not CHAT_ID: + # BOT_TOKEN / CHAT_ID are Telegram-specific. Slack and Matrix users + # don't set them — defer the actual credential check to each + # provider's own ``configure()`` (called from get_messaging_provider + # below) so non-telegram providers don't get sys.exit(1)'d here. + from app.messaging import _resolve_provider_name + if _resolve_provider_name() == "telegram" and (not BOT_TOKEN or not CHAT_ID): log("error", "Set KOAN_TELEGRAM_TOKEN and KOAN_TELEGRAM_CHAT_ID env vars.") sys.exit(1) if not INSTANCE_DIR.exists(): @@ -727,7 +732,8 @@ def main(): setup_github_auth() - provider_name = "telegram" # about to become dynamic with provider abstraction + from app.messaging import _resolve_provider_name + provider_name = _resolve_provider_name() print_bridge_banner(f"messaging bridge — {provider_name.lower()}") # Record startup time — used to ignore stale signal files in the @@ -743,8 +749,10 @@ def main(): heartbeat_file = KOAN_ROOT / HEARTBEAT_FILE heartbeat_file.unlink(missing_ok=True) write_heartbeat(str(KOAN_ROOT)) - log("init", f"Token: ...{BOT_TOKEN[-8:]}") - log("init", f"Chat ID: {CHAT_ID}") + if BOT_TOKEN: + log("init", f"Token: ...{BOT_TOKEN[-8:]}") + if CHAT_ID: + log("init", f"Chat ID: {CHAT_ID}") log("init", f"Soul: {len(SOUL)} chars loaded") log("init", f"Summary: {len(SUMMARY)} chars loaded") registry = _get_registry() @@ -811,7 +819,13 @@ def main(): msg = update.get("message", {}) text = msg.get("text", "") chat_id = str(msg.get("chat", {}).get("id", "")) - if chat_id == CHAT_ID and text: + # Match against either: (a) the active provider's channel + # id (resolved at startup — covers slack/matrix where + # CHAT_ID is unset), or (b) CHAT_ID (telegram-only, kept + # for backward compat with existing tests that patch it + # directly). For telegram in production the two are the + # same value. + if text and chat_id in (str(channel_id), str(CHAT_ID)): message_id = msg.get("message_id", 0) text = _strip_bot_mention_from_text(text, msg) log("chat", f"Received: {text[:60]}") diff --git a/koan/app/branch_limiter.py b/koan/app/branch_limiter.py index 26cab3f54..da2ee4cdb 100644 --- a/koan/app/branch_limiter.py +++ b/koan/app/branch_limiter.py @@ -31,14 +31,44 @@ def _get_local_unmerged_branches(instance_dir: str, project_name: str, return set() -def _get_open_pr_branches(github_urls: List[str], author: str) -> Set[str]: - """Return set of branch names from open PRs across all GitHub URLs.""" - if not author or not github_urls: +def _get_open_pr_branches( + project_name: str, + project_path: str, + github_urls: List[str], + author: str, +) -> Set[str]: + """Return set of branch names from open PRs for the project. + + Routes through the project's forge. The GitHub path is unchanged (iterate + the configured repo URLs via ``gh``). Non-GitHub forges (Gogs, etc.) + resolve the repo slug from the checkout and query the forge API — without + this, open PRs on a self-hosted forge are never counted, so merged work is + invisible to the saturation accounting. + """ + if not author: return set() - from app.github import list_open_pr_branches + from app.forge import get_forge + forge = get_forge(project_name) pr_branches: Set[str] = set() + + if forge.name != "github": + try: + repo = forge.repo_slug(project_path) or "" + if repo: + pr_branches.update( + forge.list_open_pr_branches(repo, author, cwd=project_path) + ) + except Exception as e: + log.debug("Failed to list open PR branches (forge=%s) for %s: %s", + forge.name, project_name, e) + return pr_branches + + if not github_urls: + return pr_branches + + from app.github import list_open_pr_branches for url in github_urls: try: branches = list_open_pr_branches(url, author) @@ -65,7 +95,9 @@ def count_pending_branches( local_branches = _get_local_unmerged_branches( instance_dir, project_name, project_path, ) - pr_branches = _get_open_pr_branches(github_urls, author) + pr_branches = _get_open_pr_branches( + project_name, project_path, github_urls, author, + ) # Union: a branch with both a local copy and an open PR counts once return len(local_branches | pr_branches) diff --git a/koan/app/deep_research.py b/koan/app/deep_research.py index 11fa22bcf..4c247cfbd 100644 --- a/koan/app/deep_research.py +++ b/koan/app/deep_research.py @@ -124,6 +124,8 @@ def extract_notes() -> str: def get_open_issues(self, limit: int = 10) -> list[dict]: """Fetch open GitHub issues for the project.""" + if not self._is_github_forge(): + return [] try: from app.github import run_gh output = run_gh( @@ -146,6 +148,9 @@ def get_pending_prs(self) -> list[dict]: """ if self._pending_prs is not None: return self._pending_prs + if not self._is_github_forge(): + self._pending_prs = [] + return self._pending_prs try: from app.github import run_gh output = run_gh( @@ -160,6 +165,22 @@ def get_pending_prs(self) -> list[dict]: self._pending_prs = [] return self._pending_prs + def _is_github_forge(self) -> bool: + """Return True if this project is on a GitHub forge. + + Gates GitHub-only issue/PR listing (which has no Gogs equivalent in + the forge interface) so it degrades quietly on self-hosted forges + instead of erroring every analysis run. Returns True on resolution + error to preserve GitHub behaviour by default. + """ + try: + from app.forge import get_forge + return get_forge(self.project_name).name == "github" + except Exception as e: + print(f"[deep_research] forge resolution failed, assuming GitHub: {e}", + file=sys.stderr) + return True + def _build_pr_coverage(self) -> dict: """Build a coverage map from open PRs. diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index 488667798..4ccd7090d 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -5,14 +5,14 @@ Resolution order in get_forge(project_name): 1. 'forge' field in projects.yaml for the project - 2. Auto-detect from 'forge_url' / 'github_url' domain (Phase 4) + 2. Auto-detect from 'forge_url' / 'github_url' domain 3. Default: GitHubForge Phase roadmap: - Phase 1 (now): GitHub, base class, registry, factory - Phase 2a: GitLabForge - Phase 2b: GiteaForge (Codeberg / Forgejo) - Phase 3: forge_auth.py (per-forge auth abstraction) + Phase 1 (done): GitHub, base class, registry, factory + Phase 2 (done): GogsForge (self-hosted Gogs instances) + Phase 3a: GitLabForge + Phase 3b: GiteaForge (Codeberg / Forgejo) Phase 4: forge_url config field + auto-detection from git remotes """ @@ -48,12 +48,32 @@ def get_forge(project_name: Optional[str] = None) -> ForgeProvider: # Unknown forge type — fall back to GitHub to avoid breaking callers. cls = GitHubForge - # TODO(Phase 2): pass base_url to all forge classes, not just GitHubForge. - if forge_url and cls is GitHubForge: + # If we have a forge url, pass it + if forge_url: return cls(base_url=forge_url) return cls() +def get_forge_for_path(project_path: str) -> ForgeProvider: + """Return a ForgeProvider for a project given only its local path. + + Convenience wrapper for callers that have a checkout path but not the + project name. Koan's workspace layout maps the directory basename to the + project key in projects.yaml, so the basename is used for config lookup. + Falls back to the default forge when the project is not configured. + + Args: + project_path: Local path to the project repository. + + Returns: + A ForgeProvider instance appropriate for the project. + """ + import os + + name = os.path.basename(os.path.normpath(project_path)) if project_path else None + return get_forge(name) + + def detect_forge_from_url(url: str) -> ForgeProvider: """Infer a ForgeProvider from a URL domain. @@ -71,14 +91,27 @@ def detect_forge_from_url(url: str) -> ForgeProvider: lower = url.lower() - if "github.com" in lower or "github.enterprise" in lower: + from urllib.parse import urlparse + parsed=urlparse(lower) + + netloc = parsed.netloc + + # While this still allows for nefarious github.enterprise.whatever, + # we presume that is intentional subdomain design in that case + if netloc.endswith("github.com") or "github.enterprise" in netloc: return GitHubForge() - # Phase 2a: gitlab.com and self-hosted GitLab + # Phase 2: self-hosted Gogs — detected by KOAN_GOGS_HOST match + gogs_host = _gogs_host_for_detection() + if netloc == gogs_host: + from app.forge.gogs import GogsForge + return GogsForge() + + # Phase 3a: gitlab.com and self-hosted GitLab # if "gitlab.com" in lower or _is_gitlab_url(lower): # return GitLabForge() - # Phase 2b: Codeberg / Forgejo / Gitea + # Phase 3b: Codeberg / Forgejo / Gitea # if "codeberg.org" in lower or "gitea.io" in lower: # return GiteaForge() @@ -100,10 +133,13 @@ def _resolve_forge_config(project_name: Optional[str]) -> tuple: return DEFAULT_FORGE, None try: - from app.utils import get_koan_root + import os from app.projects_config import load_projects_config, get_project_config - koan_root = get_koan_root() + koan_root = os.environ.get("KOAN_ROOT", "") + if not koan_root: + log.warning("KOAN_ROOT not set — cannot resolve forge for project %r", project_name) + return DEFAULT_FORGE, None config = load_projects_config(koan_root) if not config: return DEFAULT_FORGE, None @@ -111,6 +147,7 @@ def _resolve_forge_config(project_name: Optional[str]) -> tuple: project_cfg = get_project_config(config, project_name) forge_type = project_cfg.get("forge", DEFAULT_FORGE) # Support both 'forge_url' (new) and 'github_url' (legacy alias) + # TODO make the rest of the project do a similar fallback scheme, no other place is this done forge_url = project_cfg.get("forge_url") or project_cfg.get("github_url") return forge_type, forge_url @@ -124,3 +161,16 @@ def _known_forge_types() -> set: """Return the set of currently recognised forge type strings.""" from app.forge.registry import FORGE_TYPES return set(FORGE_TYPES.keys()) + + +def _gogs_host_for_detection() -> str: + """Return the lowercase Gogs host for URL detection, or empty string.""" + try: + from app.gogs_auth import get_gogs_host + host = get_gogs_host() + # Strip scheme for comparison since lower() operates on the full URL + host = host.replace("https://", "").replace("http://", "") + return host.lower() + except Exception: + log.warning("Could not resolve Gogs host for URL detection", exc_info=True) + return "" diff --git a/koan/app/forge/base.py b/koan/app/forge/base.py index 2f4187c0c..e3496652f 100644 --- a/koan/app/forge/base.py +++ b/koan/app/forge/base.py @@ -167,6 +167,50 @@ def list_merged_prs( """ raise NotImplementedError + def list_open_pr_branches( + self, + repo: str, + author: str = "", + cwd: Optional[str] = None, + ) -> List[str]: + """Return branch names (head refs) of open PRs in ``repo``. + + Args: + repo: Repository in owner/repo format. + author: Optional author login to filter by. When empty, open + PRs from all authors are returned. + cwd: Optional working directory for CLI-backed forges. + + Returns: + Sorted list of head-ref branch names. Returns an empty list on + error rather than raising, so callers can treat it as best-effort. + + Raises: + NotImplementedError: If the forge does not support PR listing. + """ + raise NotImplementedError + + def find_pr_for_branch( + self, + repo: str, + branch: str, + cwd: Optional[str] = None, + ) -> Optional[Dict]: + """Return details for the PR whose head is ``branch``, or None. + + The returned dict (when a PR is found) carries GitHub-style keys: + ``number``, ``state`` (e.g. "OPEN"/"open"), ``isDraft`` (bool), + ``url`` and ``headRefName``. + + Returns None when no PR exists for the branch. Implementations + should swallow lookup errors and return None so callers don't have + to special-case "no PR yet". + + Raises: + NotImplementedError: If the forge does not support PR lookup. + """ + raise NotImplementedError + # ------------------------------------------------------------------ # Issue operations # ------------------------------------------------------------------ @@ -261,6 +305,17 @@ def detect_fork(self, project_path: str) -> Optional[str]: """ raise NotImplementedError + def repo_slug(self, project_path: str) -> Optional[str]: + """Return the ``owner/repo`` slug for the repo checked out at path. + + Derived from the ``origin`` git remote. Returns None when there's + no origin remote or its URL can't be parsed for this forge. + + Raises: + NotImplementedError: If the forge does not support slug parsing. + """ + raise NotImplementedError + # ------------------------------------------------------------------ # Feature matrix # ------------------------------------------------------------------ diff --git a/koan/app/forge/github.py b/koan/app/forge/github.py index ed78996e1..7da39d6d8 100644 --- a/koan/app/forge/github.py +++ b/koan/app/forge/github.py @@ -131,6 +131,41 @@ def list_merged_prs( ) return [line for line in output.splitlines() if line.strip()] + def list_open_pr_branches( + self, + repo: str, + author: str = "", + cwd: Optional[str] = None, + ) -> List[str]: + from app.github import list_open_pr_branches + return list_open_pr_branches(repo, author, cwd=cwd) + + def find_pr_for_branch( + self, + repo: str, + branch: str, + cwd: Optional[str] = None, + ) -> Optional[Dict]: + # Mirror the historical `gh pr view` behaviour: with a cwd on the + # feature branch, gh infers the PR for the current branch. We pass + # the branch explicitly so the lookup also works without a checkout. + from app.github import run_gh + try: + output = run_gh( + "pr", "view", branch, + "--json", "number,state,isDraft,url,headRefName", + cwd=cwd, timeout=15, + ) + except (RuntimeError, subprocess.SubprocessError, OSError): + return None + try: + data = json.loads(output) + except (json.JSONDecodeError, TypeError): + return None + if not data or data.get("number") is None: + return None + return data + # ------------------------------------------------------------------ # Issue operations # ------------------------------------------------------------------ @@ -210,6 +245,10 @@ def detect_fork(self, project_path: str) -> Optional[str]: from app.github import detect_parent_repo return detect_parent_repo(project_path) + def repo_slug(self, project_path: str) -> Optional[str]: + from app.github import origin_repo + return origin_repo(project_path) + # ------------------------------------------------------------------ # Feature matrix # ------------------------------------------------------------------ diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py new file mode 100644 index 000000000..b8e5c6f04 --- /dev/null +++ b/koan/app/forge/gogs.py @@ -0,0 +1,557 @@ +"""Gogs forge implementation. + +GogsForge targets self-hosted Gogs instances (https://gogs.io) via the +Gogs REST API v1. Host and token are read from KOAN_GOGS_HOST and +KOAN_GOGS_TOKEN respectively. + +Supported features: + FEATURE_PR — create, view, list merged PRs + FEATURE_ISSUES — create, list open issues + +Not supported (Gogs API limitation or out of scope): + FEATURE_CI_STATUS — Gogs has no native CI API + FEATURE_REACTIONS — Gogs does not expose reaction endpoints + FEATURE_NOTIFICATIONS — handled by polling, not forge API + FEATURE_PR_REVIEW_COMMENTS — Gogs PR review API is limited +""" + +import logging +import json +import urllib.error +import urllib.parse +import urllib.request +from typing import Dict, List, Optional, Tuple + +from app.forge.base import FEATURE_ISSUES, FEATURE_PR, ForgeProvider + +log = logging.getLogger(__name__) + +class GogsForge(ForgeProvider): + """Forge implementation for self-hosted Gogs instances. + + Uses the Gogs REST API v1 directly (no CLI wrapper required at + runtime, though scripts/gogs provides a gh-compatible CLI for humans). + + Args: + base_url: Gogs base URL. Defaults to KOAN_GOGS_HOST env var. + """ + + name = "gogs" + + _SUPPORTED_FEATURES = frozenset({FEATURE_PR, FEATURE_ISSUES}) + + def __init__(self, base_url: str = ""): + from app.gogs_auth import get_gogs_host + self.base_url = (base_url or get_gogs_host()).rstrip("/") + + # ------------------------------------------------------------------ + # CLI availability (optional scripts/gogs wrapper for human use) + # ------------------------------------------------------------------ + + def cli_name(self) -> str: + return "gogs" + + # ------------------------------------------------------------------ + # Authentication + # ------------------------------------------------------------------ + + def auth_env(self) -> Dict[str, str]: + from app.gogs_auth import get_gogs_host, get_gogs_token + env = {} + host = get_gogs_host() + token = get_gogs_token() + if host: + env["KOAN_GOGS_HOST"] = host + if token: + env["KOAN_GOGS_TOKEN"] = token + return env + + # ------------------------------------------------------------------ + # URL parsing + # ------------------------------------------------------------------ + + def parse_pr_url(self, url: str) -> Tuple[str, str, str]: + from app.gogs_url_parser import parse_pr_url + return parse_pr_url(url) + + def parse_issue_url(self, url: str) -> Tuple[str, str, str]: + from app.gogs_url_parser import parse_issue_url + return parse_issue_url(url) + + def search_pr_url(self, text: str) -> Tuple[str, str, str]: + from app.gogs_url_parser import search_pr_url + return search_pr_url(text) + + def search_issue_url(self, text: str) -> Tuple[str, str, str]: + from app.gogs_url_parser import search_issue_url + return search_issue_url(text) + + # ------------------------------------------------------------------ + # PR operations + # ------------------------------------------------------------------ + + def pr_create( + self, + title: str, + body: str, + draft: bool = True, + base: Optional[str] = None, + repo: Optional[str] = None, + head: Optional[str] = None, + cwd: Optional[str] = None, + ) -> str: + """Create a pull request on the Gogs instance. + + Note: Gogs does not support draft PRs — the ``draft`` flag is + accepted for interface compatibility but has no effect. + + Args: + title: PR title. + body: PR body (markdown). + draft: Ignored (Gogs has no draft PR concept). + base: Target branch name. + repo: Repository in owner/repo format. + head: Source branch name (or owner:branch for cross-repo). + cwd: Unused (kept for interface compatibility). + + Returns: + URL of the created PR. + + Raises: + ValueError: If ``repo`` is not provided. + RuntimeError: If the API call fails. + """ + self._require_token() + owner, repo_name = _split_repo(repo) + payload: Dict = {"title": title, "body": body or ""} + if base: + payload["base"] = base + if head: + payload["head"] = head + + data = self._api("POST", f"repos/{owner}/{repo_name}/pulls", payload) + html_url = data.get("html_url") or "" + if not html_url: + number = data.get("number") + if not number: + raise RuntimeError("Could not determine created PR's URL!") + html_url = f"{self.base_url}/{owner}/{repo_name}/pulls/{number}" + return html_url + + def pr_view( + self, + repo: str, + number: int, + cwd: Optional[str] = None, + ) -> Dict: + owner, repo_name = _split_repo(repo) + data = self._api("GET", f"repos/{owner}/{repo_name}/pulls/{number}") + return _normalise_pr(data) + + def pr_diff( + self, + repo: str, + number: int, + cwd: Optional[str] = None, + ) -> str: + """Fetch the unified diff for a Gogs PR via the web endpoint. + + Gogs serves diffs at ///pulls/.diff — + this fetches that page with token authentication. + """ + owner, repo_name = _split_repo(repo) + url = f"{self.base_url}/{owner}/{repo_name}/pulls/{number}.diff" + return self._raw_get(url) + + def list_merged_prs( + self, + repo: str, + cwd: Optional[str] = None, + ) -> List[str]: + owner, repo_name = _split_repo(repo) + items = self._api( + "GET", + f"repos/{owner}/{repo_name}/pulls", + params={"state": "closed", "type": "closed", "limit": "50"}, + ) + if not isinstance(items, list): + return [] + return [ + pr.get("head", {}).get("ref", "") + for pr in items + if isinstance(pr, dict) and pr.get("merged") + ] + + def list_open_pr_branches( + self, + repo: str, + author: str = "", + cwd: Optional[str] = None, + ) -> List[str]: + """Return head-ref branch names of open PRs in ``repo``. + + Best-effort: returns an empty list on any API error so callers can + treat branch-saturation accounting as degrade-gracefully. + """ + try: + owner, repo_name = _split_repo(repo) + items = self._api( + "GET", + f"repos/{owner}/{repo_name}/pulls", + params={"state": "open", "limit": "50"}, + ) + except (RuntimeError, ValueError): + return [] + if not isinstance(items, list): + return [] + branches = set() + for pr in items: + if not isinstance(pr, dict): + continue + if author and _pr_author(pr) != author: + continue + ref = (pr.get("head") or {}).get("ref", "") + if ref: + branches.add(ref) + return sorted(branches) + + def find_pr_for_branch( + self, + repo: str, + branch: str, + cwd: Optional[str] = None, + ) -> Optional[Dict]: + """Return the PR whose head ref is ``branch``, or None. + + State is normalised to GitHub's upper-case convention + ("OPEN"/"CLOSED"/"MERGED") so callers can compare uniformly across + forges. Gogs has no draft PRs, so ``isDraft`` is always False. + """ + try: + owner, repo_name = _split_repo(repo) + items = self._api( + "GET", + f"repos/{owner}/{repo_name}/pulls", + params={"state": "all", "limit": "50"}, + ) + except (RuntimeError, ValueError): + return None + if not isinstance(items, list): + return None + for pr in items: + if not isinstance(pr, dict): + continue + if (pr.get("head") or {}).get("ref", "") != branch: + continue + if pr.get("merged"): + state = "MERGED" + elif (pr.get("state") or "").lower() == "closed": + state = "CLOSED" + else: + state = "OPEN" + return { + "number": pr.get("number"), + "state": state, + "isDraft": False, + "url": pr.get("html_url", ""), + "headRefName": branch, + } + return None + + # ------------------------------------------------------------------ + # Issue operations + # ------------------------------------------------------------------ + + def issue_create( + self, + title: str, + body: str, + labels: Optional[List[str]] = None, + cwd: Optional[str] = None, + ) -> str: + # Translate git remote in cwd to 'repo' string to pass to issue_create_in_repo + # XXX A bit wasteful to split/unsplit but beats refactoring + self._require_token() + result = _owner_repo_from_git_remote(cwd) + if not result: + raise RuntimeError(f"{cwd} is not a git repository, or has no remotes configured, so we cannot figure out how to file an issue thereupon") + # XXX Irritating bit of reassignment due to above call returning None rather than empty array, principle of least astonishment violation + owner, repo_name = result + repo = f"{owner}/{repo_name}" + return self.issue_create_in_repo(repo, title, body, labels) + + def issue_create_in_repo( + self, + repo: str, + title: str, + body: str, + labels: Optional[List[str]] = None, + ) -> str: + """Create an issue specifying the target repo explicitly. + + Args: + repo: Repository in owner/repo format. + title: Issue title. + body: Issue body (markdown). + labels: Optional list of label names. + + Returns: + URL of the created issue. + """ + self._require_token() + owner, repo_name = _split_repo(repo) + payload: Dict = {"title": title, "body": body or ""} + # Gogs label API uses IDs, not names — skip label resolution for now. + data = self._api("POST", f"repos/{owner}/{repo_name}/issues", payload) + html_url = data.get("html_url") or "" + if not html_url: + number = data.get("number") + html_url = f"{self.base_url}/{owner}/{repo_name}/issues/{number}" + return html_url + + # ------------------------------------------------------------------ + # API access + # ------------------------------------------------------------------ + + def run_api( + self, + endpoint: str, + method: str = "GET", + data: Optional[Dict] = None, + cwd: Optional[str] = None, + ) -> str: + result = self._api(method, endpoint, data) + return json.dumps(result) + + # ------------------------------------------------------------------ + # Repository introspection + # ------------------------------------------------------------------ + + def get_web_url( + self, + repo: str, + url_type: str, + number: int, + ) -> str: + owner, repo_name = _split_repo(repo) + path_map = { + "pull": "pulls", + "pr": "pulls", + "pulls": "pulls", + "issues": "issues", + "issue": "issues", + } + path = path_map.get(url_type, url_type) + return f"{self.base_url}/{owner}/{repo_name}/{path}/{number}" + + def detect_fork(self, project_path: str) -> Optional[str]: + """Detect if a Gogs repo is a fork and return the parent owner/repo. + + Uses the git remote URL to derive owner/repo, then queries the + Gogs API for the parent field. Returns None when not a fork or + on any error. + """ + owner_repo = _owner_repo_from_git_remote(project_path) + if not owner_repo: + return None + owner, repo_name = owner_repo + try: + data = self._api("GET", f"repos/{owner}/{repo_name}") + parent = data.get("parent") + if parent and isinstance(parent, dict): + p_owner = parent.get("owner", {}).get("login", "") + p_name = parent.get("name", "") + if p_owner and p_name: + return f"{p_owner}/{p_name}" + except (RuntimeError, KeyError, AttributeError, TypeError) as exc: + log.warning("Gogs fork detection failed for %s: %s", project_path, exc) + return None + + def repo_slug(self, project_path: str) -> Optional[str]: + """Return ``owner/repo`` parsed from the origin git remote, or None.""" + result = _owner_repo_from_git_remote(project_path) + if not result: + return None + owner, repo_name = result + return f"{owner}/{repo_name}" + + # ------------------------------------------------------------------ + # Feature matrix + # ------------------------------------------------------------------ + + def supports(self, feature: str) -> bool: + return feature in self._SUPPORTED_FEATURES + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _require_host(self) -> None: + if not self.base_url: + raise RuntimeError( + "Gogs host is not configured. " + "Set KOAN_GOGS_HOST to your Gogs base URL " + "(e.g. https://git.example.com)." + ) + + # CUD is one of the few times we know for a fact we will need a token + # We might also need one for read in the case of private repos though. + # In those cases we will have to fall back to enjoying a 403 from the API. + def _require_token(self) -> None: + from app.gogs_auth import get_gogs_token + if not get_gogs_token(): + raise RuntimeError( + "GOGS token is not configured. " + "Set KOAN_GOGS_TOKEN to a personal access token." + ) + + def _api( + self, + method: str, + path: str, + data: Optional[Dict] = None, + params: Optional[Dict] = None, + timeout: int = 30, + ): + """Make an authenticated Gogs API v1 request. + + Args: + method: HTTP method (GET, POST, PATCH, DELETE). + path: API path relative to /api/v1/ (e.g. "repos/owner/repo/pulls"). + data: Optional JSON payload for POST/PATCH. + params: Optional query-string parameters for GET. + timeout: Request timeout in seconds. + + Returns: + Parsed JSON response (dict or list). + + Raises: + RuntimeError: On HTTP error or if KOAN_GOGS_HOST is not set. + """ + self._require_host() + + from app.gogs_auth import get_gogs_token + + url = f"{self.base_url}/api/v1/{path.lstrip('/')}" + if params: + url = url + "?" + urllib.parse.urlencode(params) + + token = get_gogs_token() + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + + body = json.dumps(data).encode() if data is not None else None + req = urllib.request.Request( + url, data=body, headers=headers, method=method.upper() + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", errors="replace") + return json.loads(raw) if raw.strip() else {} + except urllib.error.HTTPError as exc: + raise RuntimeError( + f"Gogs API {method} {path} failed: HTTP {exc.code}" + ) from exc + except Exception as exc: + raise RuntimeError( + f"Gogs API {method} {path} error: {exc}" + ) from exc + + def _raw_get(self, url: str, timeout: int = 30) -> str: + """Fetch a raw URL (non-JSON) with token auth.""" + self._require_host() + from app.gogs_auth import get_gogs_token + + token = get_gogs_token() + headers = {} + if token: + headers["Authorization"] = f"token {token}" + + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + raise RuntimeError(f"Gogs fetch {url} failed: HTTP {exc.code}") from exc + except Exception as exc: + raise RuntimeError(f"Gogs fetch {url} error: {exc}") from exc + + +# --------------------------------------------------------------------------- +# Internal utilities +# --------------------------------------------------------------------------- + +def _split_repo(repo: Optional[str]) -> Tuple[str, str]: + """Split an owner/repo string into (owner, repo_name). + + Raises: + ValueError: If repo is empty or not in owner/repo format. + """ + if not repo: + raise ValueError("repo must be specified in owner/repo format") + parts = repo.split("/", 1) + if len(parts) != 2 or not all(parts): + raise ValueError(f"Invalid repo format: {repo!r} (expected owner/repo)") + return parts[0], parts[1] + + +def _normalise_pr(data: Dict) -> Dict: + """Map Gogs PR API fields to GitHub-compatible field names. + + Callers (e.g. ``pr_view``) expect GitHub-style field names such as + ``headRefName`` and ``baseRefName``. Gogs stores these under + ``head.ref`` and ``base.ref``. + """ + return { + "number": data.get("number"), + "title": data.get("title", ""), + "body": data.get("body", ""), + "state": data.get("state", ""), + "headRefName": (data.get("head") or {}).get("ref", ""), + "baseRefName": (data.get("base") or {}).get("ref", ""), + "url": data.get("html_url", ""), + } + + +def _pr_author(pr: Dict) -> str: + """Return the login of a Gogs PR's author. + + Gogs exposes the author under ``user`` (and historically ``poster``); + fall back across both so author filtering works on either API version. + """ + for key in ("user", "poster"): + node = pr.get(key) + if isinstance(node, dict): + login = node.get("login") or node.get("username") or "" + if login: + return login + return "" + + +def _owner_repo_from_git_remote(project_path: str) -> Optional[Tuple[str, str]]: + """Parse the git origin remote to extract (owner, repo_name).""" + import re + import subprocess + + if not project_path: + return None + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, text=True, timeout=5, + cwd=project_path, stdin=subprocess.DEVNULL, + ) + if result.returncode != 0: + return None + url = result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None + + # SSH: git@host:owner/repo.git or git@host:owner/repo + # HTTPS: https://host/owner/repo.git or https://host/owner/repo + match = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?$", url) + if match: + return match.group(1), match.group(2) + return None diff --git a/koan/app/forge/registry.py b/koan/app/forge/registry.py index 71e51c988..967bfbf0e 100644 --- a/koan/app/forge/registry.py +++ b/koan/app/forge/registry.py @@ -1,22 +1,25 @@ """Forge provider registry — maps type strings to ForgeProvider classes. Phase 1: GitHub only. -Phase 2a: GitLabForge will be added here. -Phase 2b: GiteaForge will be added here. +Phase 2: Gogs (self-hosted) added. +Phase 3a: GitLabForge will be added here. +Phase 3b: GiteaForge will be added here. """ from typing import Type from app.forge.base import ForgeProvider from app.forge.github import GitHubForge +from app.forge.gogs import GogsForge # Map forge type strings to provider classes. # Keys are the values accepted in projects.yaml under `forge:`. FORGE_TYPES: dict = { "github": GitHubForge, - # "gitlab": GitLabForge, # Phase 2a - # "gitea": GiteaForge, # Phase 2b + "gogs": GogsForge, + # "gitlab": GitLabForge, # Phase 3a + # "gitea": GiteaForge, # Phase 3b } DEFAULT_FORGE = "github" diff --git a/koan/app/git_sync.py b/koan/app/git_sync.py index e2def45c9..2b842818e 100644 --- a/koan/app/git_sync.py +++ b/koan/app/git_sync.py @@ -238,17 +238,41 @@ def _split_branches_by_recency( return recent, stale def get_github_merged_branches(self) -> List[str]: - """Find agent branches whose GitHub PRs have been merged. + """Find agent branches whose PRs have been merged on the forge. - Uses ``gh pr list --state merged`` to batch-detect branches that - were squash-merged or rebase-merged — invisible to - ``git branch --merged`` since commit SHAs change. + Batch-detects branches that were squash-merged or rebase-merged — + invisible to ``git branch --merged`` since commit SHAs change. Routes + through the project's forge so this works on GitHub *and* self-hosted + forges (Gogs, etc.); the GitHub path is unchanged. Without this, a + non-GitHub project's branches are never recognised as merged, so the + project stays permanently branch-saturated. Returns: - Sorted list of branch names whose PRs are merged on GitHub. - Returns empty list on error (no gh CLI, not a GitHub repo, etc.). + Sorted list of branch names whose PRs are merged on the forge. + Returns empty list on error (no CLI, unknown repo, etc.). """ prefix = _get_prefix() + + from app.forge import get_forge + forge = get_forge(self.project_name) + + if forge.name != "github": + # Non-GitHub forge: resolve the repo slug and ask the forge for + # merged PR branches. list_merged_prs is best-effort and swallows + # its own errors, so we only guard slug resolution here. + try: + repo = forge.repo_slug(self.project_path) or "" + if not repo: + return [] + merged = forge.list_merged_prs(repo, cwd=self.project_path) + except (RuntimeError, OSError, ValueError, NotImplementedError) as e: + log.debug("Forge %s: failed to list merged PRs: %s", forge.name, e) + return [] + return sorted({ + ref for ref in merged + if isinstance(ref, str) and ref.startswith(prefix) + }) + try: from app.github import run_gh raw = run_gh( diff --git a/koan/app/gogs_auth.py b/koan/app/gogs_auth.py new file mode 100644 index 000000000..03a6a84bb --- /dev/null +++ b/koan/app/gogs_auth.py @@ -0,0 +1,39 @@ +"""Gogs authentication helpers. + +Reads KOAN_GOGS_HOST and KOAN_GOGS_TOKEN from the environment. +Both values are required for any Gogs API interaction. + +Env vars: + KOAN_GOGS_HOST — Base URL of the Gogs instance (e.g. https://git.example.com) + KOAN_GOGS_TOKEN — Personal access token for authentication +""" + +import os +from typing import Dict + + +def get_gogs_host() -> str: + """Return the configured Gogs base URL, stripped of trailing slashes.""" + return os.environ.get("KOAN_GOGS_HOST", "").rstrip("/") + + +def get_gogs_token() -> str: + """Return the configured Gogs API token.""" + return os.environ.get("KOAN_GOGS_TOKEN", "") + + +def get_gogs_auth_headers() -> Dict[str, str]: + """Return HTTP headers for authenticated Gogs API requests. + + Returns an empty dict when KOAN_GOGS_TOKEN is not set, which will + result in anonymous (read-only) access to public repos. + """ + token = get_gogs_token() + if token: + return {"Authorization": f"token {token}"} + return {} + + +def is_gogs_configured() -> bool: + """Return True if both KOAN_GOGS_HOST and KOAN_GOGS_TOKEN are set.""" + return bool(get_gogs_host() and get_gogs_token()) diff --git a/koan/app/gogs_url_parser.py b/koan/app/gogs_url_parser.py new file mode 100644 index 000000000..a61f849f8 --- /dev/null +++ b/koan/app/gogs_url_parser.py @@ -0,0 +1,134 @@ +"""URL parsing utilities for self-hosted Gogs instances. + +Gogs URL shapes: + PR: https://///pulls/ + Issue: https://///issues/ + +Unlike GitHub the host is variable (set via KOAN_GOGS_HOST), so patterns +are built at call time rather than compiled as module-level constants. +""" + +import re +from typing import Optional, Tuple + +from app.gogs_auth import get_gogs_host + + +def _host_pattern() -> str: + """Return an escaped regex fragment for the configured Gogs host.""" + host = get_gogs_host() + if not host: + raise ValueError( + "KOAN_GOGS_HOST is not configured. " + "Set it to your Gogs base URL (e.g. https://git.example.com)." + ) + return re.escape(host) + + +def parse_pr_url(url: str) -> Tuple[str, str, str]: + """Extract (owner, repo, pr_number) from a Gogs PR URL. + + Gogs PR URLs use '/pulls/' (plural), e.g.: + https://git.example.com/owner/repo/pulls/42 + + Args: + url: Gogs PR URL. + + Returns: + Tuple of (owner, repo, pr_number) as strings. + + Raises: + ValueError: If the URL does not match the expected pattern. + """ + pattern = rf"{_host_pattern()}/([^/]+)/([^/]+)/pulls/(\d+)" + match = re.match(pattern, url.strip()) + if not match: + raise ValueError(f"Invalid Gogs PR URL: {url!r}") + return match.group(1), match.group(2), match.group(3) + + +def parse_issue_url(url: str) -> Tuple[str, str, str]: + """Extract (owner, repo, issue_number) from a Gogs issue URL. + + Args: + url: Gogs issue URL (e.g. https://git.example.com/owner/repo/issues/5). + + Returns: + Tuple of (owner, repo, issue_number) as strings. + + Raises: + ValueError: If the URL does not match the expected pattern. + """ + pattern = rf"{_host_pattern()}/([^/]+)/([^/]+)/issues/(\d+)" + match = re.match(pattern, url.strip()) + if not match: + raise ValueError(f"Invalid Gogs issue URL: {url!r}") + return match.group(1), match.group(2), match.group(3) + + +def search_pr_url(text: str) -> Tuple[str, str, str]: + """Search for a Gogs PR URL anywhere in text. + + Args: + text: Text that may contain a Gogs PR URL. + + Returns: + Tuple of (owner, repo, pr_number) as strings. + + Raises: + ValueError: If no PR URL is found in text. + """ + pattern = rf"{_host_pattern()}/([^/]+)/([^/]+)/pulls/(\d+)" + match = re.search(pattern, text) + if not match: + raise ValueError(f"No Gogs PR URL found in: {text!r}") + return match.group(1), match.group(2), match.group(3) + + +def search_issue_url(text: str) -> Tuple[str, str, str]: + """Search for a Gogs issue URL anywhere in text. + + Args: + text: Text that may contain a Gogs issue URL. + + Returns: + Tuple of (owner, repo, issue_number) as strings. + + Raises: + ValueError: If no issue URL is found in text. + """ + pattern = rf"{_host_pattern()}/([^/]+)/([^/]+)/issues/(\d+)" + match = re.search(pattern, text) + if not match: + raise ValueError(f"No Gogs issue URL found in: {text!r}") + return match.group(1), match.group(2), match.group(3) + + +def build_pr_url( + base_url: str, owner: str, repo: str, number: int +) -> str: + """Build a Gogs PR web URL.""" + return f"{base_url.rstrip('/')}/{owner}/{repo}/pulls/{number}" + + +def build_issue_url( + base_url: str, owner: str, repo: str, number: int +) -> str: + """Build a Gogs issue web URL.""" + return f"{base_url.rstrip('/')}/{owner}/{repo}/issues/{number}" + + +def is_gogs_url(url: str, base_url: Optional[str] = None) -> bool: + """Return True if the URL belongs to the configured Gogs instance. + + Args: + url: URL to check. + base_url: Optional base URL override (defaults to KOAN_GOGS_HOST). + + Returns: + True if the URL's host matches the configured Gogs host. + """ + host = (base_url or get_gogs_host()).rstrip("/") + if not host or not url: + return False + return url.lower().startswith(host.lower()) diff --git a/koan/app/messaging/matrix.py b/koan/app/messaging/matrix.py index 6226191f3..00ed36c72 100644 --- a/koan/app/messaging/matrix.py +++ b/koan/app/messaging/matrix.py @@ -210,16 +210,38 @@ def _parse_room_events(self, sync_data: dict) -> List[Update]: if not body: continue + # awake.py's main loop expects Telegram-Bot-API-shaped dicts + # (update["update_id"], update["message"]["chat"]["id"], …). + # Mint that wrapper here so the polling loop doesn't care which + # provider it's draining. + ts = event.get("origin_server_ts", "") + update_id = next(self._update_counter) + raw = { + "update_id": update_id, + "message": { + "message_id": event.get("event_id", ""), + "text": body, + "date": ts, + "chat": {"id": self._room_id, "type": "supergroup"}, + "from": {"id": sender, "username": sender}, + }, + "_matrix": { + "sender": sender, + "event_id": event.get("event_id", ""), + "room_id": self._room_id, + "origin_server_ts": ts, + }, + } updates.append( Update( - update_id=next(self._update_counter), + update_id=update_id, message=Message( text=body, role="user", - timestamp=str(event.get("origin_server_ts", "")), - raw_data=event, + timestamp=str(ts), + raw_data=raw, ), - raw_data=event, + raw_data=raw, ) ) return updates diff --git a/koan/app/mission_verifier.py b/koan/app/mission_verifier.py index 257208ed0..272628000 100644 --- a/koan/app/mission_verifier.py +++ b/koan/app/mission_verifier.py @@ -202,7 +202,10 @@ def check_test_coverage(project_path: str, mission_title: str) -> Check: def check_pr_created(project_path: str, mission_title: str) -> Check: """Verify that a draft PR was created for code-changing missions. - Uses `gh pr view` to check for an existing PR on the current branch. + Routes the PR lookup through the project's forge so the check works on + GitHub *and* self-hosted forges (Gogs, etc.) — previously it called + ``gh`` unconditionally, which failed for every non-GitHub project and + spammed the log with "known GitHub host" errors each iteration. """ if _is_analysis_mission(mission_title): return Check( @@ -215,37 +218,41 @@ def check_pr_created(project_path: str, mission_title: str) -> Check: if rc != 0 or branch in ("main", "master", ""): return Check("pr_created", CheckStatus.SKIP, "Not on feature branch") - # Check for PR via gh CLI + # Look up the PR via the forge abstraction. try: - from app.github import run_gh - pr_json = run_gh( - "pr", "view", "--json", "number,state,isDraft", - cwd=project_path, timeout=10, - ) - import json - pr_data = json.loads(pr_json) - pr_num = pr_data.get("number") - is_draft = pr_data.get("isDraft", False) - state = pr_data.get("state", "") - - if state == "OPEN": - draft_info = " (draft)" if is_draft else "" - return Check( - "pr_created", CheckStatus.PASS, - f"PR #{pr_num}{draft_info} exists" - ) + from app.forge import get_forge_for_path + forge = get_forge_for_path(project_path) + repo = forge.repo_slug(project_path) or "" + pr_data = forge.find_pr_for_branch(repo, branch, cwd=project_path) + except Exception as e: + # Forge not available / lookup error — don't fail the mission over it. + print(f"[verifier] PR check failed: {e}", file=sys.stderr) return Check( "pr_created", CheckStatus.WARN, - f"PR #{pr_num} exists but state is {state}" + "No PR found for current branch" ) - except Exception as e: - # No PR or gh not available - print(f"[verifier] PR check failed: {e}", file=sys.stderr) + + if not pr_data: return Check( "pr_created", CheckStatus.WARN, "No PR found for current branch" ) + pr_num = pr_data.get("number") + is_draft = pr_data.get("isDraft", False) + state = (pr_data.get("state") or "").upper() + + if state == "OPEN": + draft_info = " (draft)" if is_draft else "" + return Check( + "pr_created", CheckStatus.PASS, + f"PR #{pr_num}{draft_info} exists" + ) + return Check( + "pr_created", CheckStatus.WARN, + f"PR #{pr_num} exists but state is {state}" + ) + def check_commit_quality(project_path: str) -> Check: """Verify commit messages are clean and well-formed. diff --git a/koan/app/pr_feedback.py b/koan/app/pr_feedback.py index f0fa39692..e8dfd0a8b 100644 --- a/koan/app/pr_feedback.py +++ b/koan/app/pr_feedback.py @@ -49,6 +49,22 @@ SLOW_MERGE_HOURS = 168 # 7 days +def _is_github_forge(project_path: str) -> bool: + """Return True if the project at ``project_path`` is on a GitHub forge. + + Used to gate GitHub-only PR analytics so they degrade quietly (rather + than erroring) on self-hosted forges like Gogs. Returns True on any + resolution error so GitHub behaviour is never accidentally suppressed. + """ + try: + from app.forge import get_forge_for_path + return get_forge_for_path(project_path).name == "github" + except Exception as e: + print(f"[pr_feedback] forge resolution failed, assuming GitHub: {e}", + file=sys.stderr) + return True + + def categorize_pr(title: str) -> str: """Categorize a PR by work type from its title. @@ -121,6 +137,13 @@ def fetch_merged_prs( List of PR dicts with keys: number, title, createdAt, mergedAt, headRefName, category, hours_to_merge. """ + # Merge-velocity analytics rely on GitHub-specific PR metadata + # (createdAt/mergedAt). On non-GitHub forges, skip quietly rather than + # firing `gh` at a repo it can't resolve — which used to log an error + # every iteration. The feature simply doesn't apply to those projects. + if not _is_github_forge(project_path): + return [] + try: from app.github import run_gh except ImportError: @@ -199,6 +222,9 @@ def fetch_open_prs(project_path: str) -> List[dict]: List of PR dicts with: number, title, createdAt, headRefName, category, hours_open. """ + if not _is_github_forge(project_path): + return [] + try: from app.github import run_gh except ImportError: diff --git a/koan/app/pr_quality.py b/koan/app/pr_quality.py index 4f495fc8d..23eebfebd 100644 --- a/koan/app/pr_quality.py +++ b/koan/app/pr_quality.py @@ -47,6 +47,22 @@ ] +def _is_github_forge(project_path: str) -> bool: + """Return True if the project at ``project_path`` is on a GitHub forge. + + Gates GitHub-only PR mutation (``gh pr edit`` / ``gh pr comment``), which + has no Gogs equivalent, so it degrades quietly on self-hosted forges. + Returns True on resolution error to preserve GitHub behaviour by default. + """ + try: + from app.forge import get_forge_for_path + return get_forge_for_path(project_path).name == "github" + except Exception as e: + print(f"[pr_quality] forge resolution failed, assuming GitHub: {e}", + file=sys.stderr) + return True + + def _get_base_ref(project_path: str) -> Optional[str]: """Determine the base ref for diffing (upstream/main or origin/main).""" for ref in ("upstream/main", "origin/main", "upstream/master", "origin/master"): @@ -369,6 +385,12 @@ def enrich_pr_description(project_path: str, quality_report: dict) -> Optional[s Returns: PR URL if enriched, None if no PR found or enrichment skipped. """ + # PR-body enrichment uses `gh pr edit`, which has no Gogs equivalent in + # the forge interface. Skip quietly on non-GitHub forges instead of + # erroring on every mission's post-run pipeline. + if not _is_github_forge(project_path): + return None + from app.github import run_gh # Check if a PR exists for the current branch @@ -523,6 +545,11 @@ def post_quality_comment(project_path: str, quality_report: dict) -> bool: Returns True if comment was posted. """ + # Commenting uses `gh pr comment`, which has no Gogs equivalent in the + # forge interface. Skip quietly on non-GitHub forges. + if not _is_github_forge(project_path): + return False + from app.github import run_gh, sanitize_github_comment # Only comment if there are actual issues diff --git a/koan/app/pr_submit.py b/koan/app/pr_submit.py index 13039b162..5104dec45 100644 --- a/koan/app/pr_submit.py +++ b/koan/app/pr_submit.py @@ -49,7 +49,7 @@ def get_commit_subjects(project_path: str, base_branch: str = "main") -> List[st def get_fork_owner(project_path: str) -> str: - """Return the GitHub owner login of the PR head (the push target). + """Return the forge owner login of the PR head (the push target). Derived from the ``origin`` git remote — the branch is pushed there, so the cross-fork ``--head :`` must name the same owner. @@ -57,7 +57,17 @@ def get_fork_owner(project_path: str) -> str: remote exists it resolves to the upstream/base repo and reports the *upstream* owner, which would point ``--head`` at a branch that doesn't exist on upstream and silently land the PR on the fork instead. + + On non-GitHub forges the owner is derived from the forge's repo slug + (host-agnostic git-remote parse); the GitHub path is unchanged. """ + from app.forge import get_forge_for_path + forge = get_forge_for_path(project_path) + + if forge.name != "github": + slug = forge.repo_slug(project_path) + return slug.split("/", 1)[0] if slug else "" + slug = origin_repo(project_path) if slug: return slug.split("/", 1)[0] @@ -97,10 +107,22 @@ def resolve_submit_target( if submit_cfg.get("repo"): return {"repo": submit_cfg["repo"], "is_fork": True} + # Detect a fork via the project's forge. On GitHub this is unchanged: # resolve_target_repo falls back to the `upstream` git remote when the - # GitHub fork-parent lookup comes back empty (e.g. gh resolved the local - # repo to the upstream itself, which reports no parent). - upstream = resolve_target_repo(project_path) + # fork-parent lookup comes back empty (e.g. gh resolved the local repo to + # the upstream itself, which reports no parent). Non-GitHub forges use the + # forge's own fork detection. + from app.forge import get_forge + forge = get_forge(project_name) + + if forge.name != "github": + try: + upstream = forge.detect_fork(project_path) + except (NotImplementedError, RuntimeError, OSError): + upstream = None + else: + upstream = resolve_target_repo(project_path) + if upstream: return {"repo": upstream, "is_fork": True} @@ -223,12 +245,28 @@ def _post_issue_comment(body: str) -> None: ) return None + # Resolve the project's forge once — used for the existing-PR check here + # and for PR creation below. The GitHub path is unchanged; non-GitHub + # forges (Gogs, etc.) go through the forge API so they can actually + # detect and create PRs instead of failing on `gh`. + from app.forge import get_forge + forge = get_forge(project_name) + # Check for existing PR on this branch try: - existing = run_gh( - "pr", "list", "--head", branch, "--json", "url", "--jq", ".[0].url", - cwd=project_path, timeout=15, - ).strip() + if forge.name == "github": + existing = run_gh( + "pr", "list", "--head", branch, "--json", "url", "--jq", ".[0].url", + cwd=project_path, timeout=15, + ).strip() + else: + repo_slug = forge.repo_slug(project_path) or "" + pr = forge.find_pr_for_branch(repo_slug, branch, cwd=project_path) + existing = ( + pr.get("url", "") + if pr and (pr.get("state") or "").upper() == "OPEN" + else "" + ) if existing: logger.info("PR already exists: %s", existing) return existing @@ -298,8 +336,19 @@ def _post_issue_comment(body: str) -> None: if fork_owner: pr_kwargs["head"] = f"{fork_owner}:{branch}" + # `gh pr create` infers repo/head/base from the local checkout, but the + # forge REST APIs (e.g. Gogs) cannot — they need them stated explicitly. + # Fill in any that weren't already set so same-repo PRs work too. + if forge.name != "github": + pr_kwargs.setdefault("repo", forge.repo_slug(project_path) or target["repo"]) + pr_kwargs.setdefault("head", branch) + pr_kwargs.setdefault("base", effective_base) + try: - pr_url = pr_create(**pr_kwargs) + if forge.name == "github": + pr_url = pr_create(**pr_kwargs) + else: + pr_url = forge.pr_create(**pr_kwargs) except (RuntimeError, OSError, subprocess.SubprocessError) as e: reason = f"gh pr create failed: {str(e)[:300]}" logger.warning(reason) diff --git a/koan/skills/core/add_project/handler.py b/koan/skills/core/add_project/handler.py index ed9a5a8c3..18f886c25 100644 --- a/koan/skills/core/add_project/handler.py +++ b/koan/skills/core/add_project/handler.py @@ -11,6 +11,8 @@ import os import re from pathlib import Path +from urllib import parse +import subprocess from app.git_utils import run_git_strict @@ -30,12 +32,17 @@ def handle(ctx): url, project_name = _parse_args(args) if not url: - return "Could not parse a GitHub URL or owner/repo from the arguments." + return "Could not parse a Git Repo URL or owner/repo from the arguments." owner, repo = _extract_owner_repo(url) if not owner or not repo: return f"Could not extract owner/repo from: {url}" + parsed = parse.urlparse(url) + host = parsed.netloc + if not host: + return "Could not determine hostname of your git server" + if not project_name: project_name = repo @@ -55,7 +62,7 @@ def handle(ctx): workspace_dir.mkdir(exist_ok=True) # Check push access BEFORE cloning — determines setup strategy - has_push = _check_push_access_safe(owner, repo) + has_push = _check_push_access_safe(host, owner, repo) if has_push: ctx.send_message( @@ -69,9 +76,9 @@ def handle(ctx): ) # Clone the repository from upstream - clone_url = f"https://github.com/{owner}/{repo}.git" + clone_url = f"https://{host}/{owner}/{repo}.git" try: - _git_clone(clone_url, str(project_dir)) + _git_clone(host, clone_url, str(project_dir)) except RuntimeError as e: return f"Clone failed: {e}" @@ -80,7 +87,7 @@ def handle(ctx): if not has_push: try: fork_url = _create_fork_and_configure( - owner, repo, str(project_dir) + host, owner, repo, str(project_dir) ) forked = True except RuntimeError as e: @@ -123,6 +130,8 @@ def _parse_args(args): # Normalize the URL url = _normalize_github_url(url_part) + if not url: + url = _normalize_gogs_url(url_part) return url, name_part @@ -157,11 +166,53 @@ def _normalize_github_url(raw): return None +def _normalize_gogs_url(raw): + """Normalize various GitHub URL formats to https://github.com/owner/repo. + + Returns the normalized URL or None if not recognizable. + """ + + raw = raw.strip().rstrip("/") + + # Try and figure out what host this url is at + parsed = parse.urlparse(raw) + host = parsed.netloc + if not host: + parsed = parse.urlparse(f"git://{raw}") + host = parsed.netloc + if not host: + return None + + # HTTPS URL: https://github.com/owner/repo[.git] + m = re.match( + r"https?://"+re.escape(host)+r"/([a-zA-Z0-9._-]+)/([a-zA-Z0-9._-]+?)(?:\.git)?$", + raw, + ) + if m: + return f"https://{host}/{m.group(1)}/{m.group(2)}" + + # SSH URL: git@github.com:owner/repo[.git] + m = re.match( + r"git@"+re.escape(host)+r":([a-zA-Z0-9._-]+)/([a-zA-Z0-9._-]+?)(?:\.git)?$", + raw, + ) + if m: + return f"https://{host}/{m.group(1)}/{m.group(2)}" + + return None def _extract_owner_repo(url): - """Extract (owner, repo) from a normalized GitHub URL.""" + """Extract (owner, repo) from a normalized URL.""" + + parsed = parse.urlparse(url) + host = parsed.netloc + if not host: + # Callers unpack the result as ``owner, repo = _extract_owner_repo(...)``, + # so always return a 2-tuple — a bare None would raise TypeError. + return None, None + m = re.match( - r"https?://github\.com/([a-zA-Z0-9._-]+)/([a-zA-Z0-9._-]+?)(?:\.git)?$", + r"https?://"+re.escape(host)+r"/([a-zA-Z0-9._-]+)/([a-zA-Z0-9._-]+?)(?:\.git)?$", url, ) if m: @@ -169,7 +220,7 @@ def _extract_owner_repo(url): return None, None -def _git_clone(url, target_dir): +def _git_clone(host, url, target_dir): """Clone a git repository. Uses ``gh repo clone`` rather than a bare ``git clone`` so that private @@ -178,32 +229,53 @@ def _git_clone(url, target_dir): prompt (stdin is closed), so it fails on private repos with "could not read Username for 'https://github.com': Device not configured". + In the event you use a repo which does not have a tool like gh, we will + use git-clone normally. + Raises RuntimeError on failure. """ - from app.github import run_gh - run_gh("repo", "clone", url, target_dir, timeout=120) + if host.endswith("github.com"): + from app.github import run_gh + return run_gh("repo", "clone", url, target_dir, timeout=120) + + return _run_git_clone(url, target_dir, timeout=120) +def _run_git_clone(url, target_dir, timeout): + try: + result = subprocess.run( + ["git", "clone", url, target_dir], + capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL, + ) + if result.returncode == 0: + return result.stdout + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None -def _check_push_access(owner, repo): +def _check_push_access(host, owner, repo): """Check if the current gh user has push access to owner/repo. Returns True if push/admin/maintain, False otherwise. Raises on network/auth errors — callers should handle exceptions. """ - from app.github import run_gh + if host.endswith("github.com"): + from app.github import run_gh + output = run_gh( + "repo", "view", f"{owner}/{repo}", + "--json", "viewerPermission", + "--jq", ".viewerPermission", + timeout=15, + ) + permission = output.strip().upper() + return permission in ("ADMIN", "MAINTAIN", "WRITE") - output = run_gh( - "repo", "view", f"{owner}/{repo}", - "--json", "viewerPermission", - "--jq", ".viewerPermission", - timeout=15, - ) - permission = output.strip().upper() - return permission in ("ADMIN", "MAINTAIN", "WRITE") + # Otherwise, maybe we're using gogs? + # TODO actually check gogs + raise RuntimeError("Cannot check push access; unsupported git repository.") + -def _check_push_access_safe(owner, repo): +def _check_push_access_safe(host, owner, repo): """Check push access with retry and logging. Returns True if push access confirmed, False if no access or check failed. @@ -211,7 +283,7 @@ def _check_push_access_safe(owner, repo): """ for attempt in range(2): try: - has_push = _check_push_access(owner, repo) + has_push = _check_push_access(host, owner, repo) logger.info( "Push access check for %s/%s: %s", owner, repo, "granted" if has_push else "denied", @@ -232,7 +304,7 @@ def _check_push_access_safe(owner, repo): return False -def _create_fork_and_configure(owner, repo, project_dir): +def _create_fork_and_configure(host, owner, repo, project_dir): """Create a personal fork and reconfigure remotes. - Fork via gh repo fork @@ -242,34 +314,37 @@ def _create_fork_and_configure(owner, repo, project_dir): Returns the fork URL string. Raises RuntimeError on failure. """ - from app.github import run_gh + if (host.endswith("github.com")): + from app.github import run_gh - # Create fork (gh repo fork does not clone — it creates on GitHub) - try: - run_gh( - "repo", "fork", f"{owner}/{repo}", - "--clone=false", - timeout=60, - ) - except RuntimeError as e: - # gh returns error if fork already exists — that's fine - if "already exists" not in str(e).lower(): - raise + # Create fork (gh repo fork does not clone — it creates on GitHub) + try: + run_gh( + "repo", "fork", f"{owner}/{repo}", + "--clone=false", + timeout=60, + ) + except RuntimeError as e: + # gh returns error if fork already exists — that's fine + if "already exists" not in str(e).lower(): + raise - # Determine the fork URL (current gh user's fork) - gh_user = _get_gh_username() - if not gh_user: - raise RuntimeError("Cannot determine GitHub username for fork URL") + # Determine the fork URL (current gh user's fork) + gh_user = _get_gh_username() + if not gh_user: + raise RuntimeError("Cannot determine GitHub username for fork URL") - fork_url = f"https://github.com/{gh_user}/{repo}.git" - original_url = f"https://github.com/{owner}/{repo}.git" + fork_url = f"https://github.com/{gh_user}/{repo}.git" + original_url = f"https://github.com/{owner}/{repo}.git" - # Reconfigure remotes: origin=fork, upstream=original - run_git_strict("remote", "rename", "origin", "upstream", cwd=project_dir) - run_git_strict("remote", "add", "origin", fork_url, cwd=project_dir) + # Reconfigure remotes: origin=fork, upstream=original + run_git_strict("remote", "rename", "origin", "upstream", cwd=project_dir) + run_git_strict("remote", "add", "origin", fork_url, cwd=project_dir) - return f"{gh_user}/{repo}" + return f"{gh_user}/{repo}" + #TODO handle GOGS forking + raise RuntimeError("Cannot create fork on unknown repository type.") def _get_gh_username(): """Get the current GitHub username.""" diff --git a/koan/skills/core/review/handler.py b/koan/skills/core/review/handler.py index cedc6773f..a5d0acc2e 100644 --- a/koan/skills/core/review/handler.py +++ b/koan/skills/core/review/handler.py @@ -1,5 +1,6 @@ """Kōan review skill -- queue a code review mission.""" +import re from typing import Optional, Tuple from app.github_url_parser import parse_github_url @@ -13,6 +14,8 @@ queue_github_mission, ) +_GOGS_SUBPATH_NAMES = frozenset(("issues", "pulls", "releases", "wiki", "settings")) + def _list_open_prs(owner: str, repo: str, limit: Optional[int] = None) -> list: """List open pull requests from a GitHub repo using gh CLI. @@ -36,6 +39,100 @@ def _list_open_prs(owner: str, repo: str, limit: Optional[int] = None) -> list: return json.loads(output) +def _list_gogs_open_prs(owner: str, repo: str, limit: Optional[int] = None) -> list: + """List open pull requests from a Gogs repo via the API. + + Returns list of dicts with 'number', 'title', and 'url' keys. + """ + from app.forge.gogs import GogsForge + from app.gogs_auth import get_gogs_host + + forge = GogsForge() + full_repo = f"{owner}/{repo}" + items = forge._api( + "GET", + f"repos/{owner}/{repo}/pulls", + params={"state": "open", "limit": str(limit or 100)}, + ) + if not isinstance(items, list): + return [] + + host = get_gogs_host().rstrip("/") + result = [] + for pr in items: + if not isinstance(pr, dict): + continue + number = pr.get("number") + if not number: + continue + url = pr.get("html_url") or f"{host}/{owner}/{repo}/pulls/{number}" + result.append({ + "number": number, + "title": pr.get("title", ""), + "url": url, + }) + return result + + +def _try_extract_gogs_pr_or_issue(args: str): + """Try to extract a Gogs PR or issue URL from args. + + Returns (owner, repo, number, type_label) or None. + type_label is "PR" or "issue". + """ + try: + from app.gogs_url_parser import search_pr_url + owner, repo, number = search_pr_url(args) + return owner, repo, number, "PR" + except ValueError: + pass + + try: + from app.gogs_url_parser import search_issue_url + owner, repo, number = search_issue_url(args) + return owner, repo, number, "issue" + except ValueError: + pass + + return None + + +def _parse_gogs_repo_url(args: str) -> Optional[Tuple[str, str, str]]: + """Extract a bare Gogs repo URL (no PR/issue number) from args. + + Returns (url, owner, repo) or None if args contain a PR/issue URL + or no valid Gogs repo URL. + """ + try: + from app.gogs_auth import get_gogs_host + except ImportError: + return None + + host = get_gogs_host() + if not host: + return None + + host_escaped = re.escape(host.rstrip("/")) + + # Reject if there's already a PR or issue URL + if re.search(rf'{host_escaped}/[^/\s]+/[^/\s]+/(?:pulls|issues)/\d+', args): + return None + + match = re.search(rf'({host_escaped}/([^/\s]+)/([^/\s]+?)(?:\.git)?)(?=/|\s|$)', args) + if not match: + return None + + url = match.group(1) + owner = match.group(2) + repo = match.group(3) + + # Reject if "repo" is a sub-path name + if repo in _GOGS_SUBPATH_NAMES: + return None + + return url, owner, repo + + def handle(ctx): """Handle /review command -- queue a code review mission. @@ -44,6 +141,9 @@ def handle(ctx): /review https://github.com/owner/repo/issues/42 /review https://github.com/owner/repo (batch: all open PRs) /review https://github.com/owner/repo --limit=5 (batch: 5 most recent) + /review https://git.example.com/owner/repo/pulls/42 + /review https://git.example.com/owner/repo/issues/42 + /review https://git.example.com/owner/repo (batch: all open Gogs PRs) """ args = ctx.args.strip() if ctx.args else "" @@ -51,12 +151,23 @@ def handle(ctx): urgent, args = extract_now_flag(args) ctx.args = args - # Check for batch mode: repo URL without issue/PR number + # ── Gogs batch mode ─────────────────────────────────────────────── + gogs_repo = _parse_gogs_repo_url(args) + if gogs_repo: + return _handle_gogs_batch(ctx, args, gogs_repo, urgent) + + # ── GitHub batch mode ───────────────────────────────────────────── repo_match = parse_repo_url(args) if repo_match: return _handle_batch(ctx, args, repo_match) - # Single PR/issue mode: delegate to unified handler + # ── Gogs single PR/issue ────────────────────────────────────────── + gogs = _try_extract_gogs_pr_or_issue(args) + if gogs: + owner, repo, number, type_label = gogs + return _handle_gogs_single(ctx, args, owner, repo, number, type_label, urgent) + + # ── GitHub single PR/issue ──────────────────────────────────────── return handle_github_skill( ctx, command="review", @@ -68,7 +179,7 @@ def handle(ctx): def _handle_batch(ctx, args: str, repo_match: Tuple[str, str, str]) -> str: - """Handle batch /review: list open PRs from repo and queue a review for each.""" + """Handle batch /review: list open PRs from GitHub repo and queue a review for each.""" url, owner, repo = repo_match limit = parse_limit(args) @@ -81,7 +192,7 @@ def _handle_batch(ctx, args: str, repo_match: Tuple[str, str, str]) -> str: try: prs = _list_open_prs(owner, repo, limit=limit) except (RuntimeError, ValueError) as e: - return f"\u274c Failed to list PRs for {owner}/{repo}: {e}" + return f"❌ Failed to list PRs for {owner}/{repo}: {e}" if not prs: return f"No open PRs found in {owner}/{repo}." @@ -97,3 +208,64 @@ def _handle_batch(ctx, args: str, repo_match: Tuple[str, str, str]) -> str: if queued == 0: return f"All PRs from {owner}/{repo} already queued or running{limit_note}." return f"Queued {queued} /review missions for {owner}/{repo}{limit_note}." + + +def _handle_gogs_single( + ctx, args: str, owner: str, repo: str, number: str, type_label: str, urgent: bool +) -> str: + """Handle /review for a single Gogs PR or issue.""" + from app.gogs_url_parser import build_pr_url, build_issue_url + from app.gogs_auth import get_gogs_host + + host = get_gogs_host() + + if type_label == "PR": + url = build_pr_url(host, owner, repo, int(number)) + else: + url = build_issue_url(host, owner, repo, int(number)) + + project_path, project_name = resolve_project_for_repo(repo, owner=owner) + if not project_path: + return format_project_not_found_error(repo, owner=owner) + + inserted = queue_github_mission(ctx, "review", url, project_name, urgent=urgent) + if not inserted: + priority = " (priority)" if urgent else "" + return ( + f"⚠️ Duplicate ignored — /review{priority} already queued " + f"or running for Gogs {type_label} #{number} ({owner}/{repo})." + ) + + priority = " (priority)" if urgent else "" + return f"Review queued{priority} for Gogs {type_label} #{number} ({owner}/{repo})." + + +def _handle_gogs_batch( + ctx, args: str, repo_match: Tuple[str, str, str], urgent: bool +) -> str: + """Handle batch /review for a Gogs repo: queue a review for each open PR.""" + url, owner, repo = repo_match + limit = parse_limit(args) + + project_path, project_name = resolve_project_for_repo(repo, owner=owner) + if not project_path: + return format_project_not_found_error(repo, owner=owner) + + try: + prs = _list_gogs_open_prs(owner, repo, limit=limit) + except (RuntimeError, ValueError) as e: + return f"❌ Failed to list Gogs PRs for {owner}/{repo}: {e}" + + if not prs: + return f"No open PRs found in Gogs repo {owner}/{repo}." + + queued = 0 + for pr in prs: + pr_url = pr.get("url", "") + if pr_url and queue_github_mission(ctx, "review", pr_url, project_name, urgent=urgent): + queued += 1 + + limit_note = f" (limited to {limit})" if limit else "" + if queued == 0: + return f"All Gogs PRs from {owner}/{repo} already queued or running{limit_note}." + return f"Queued {queued} /review missions for Gogs repo {owner}/{repo}{limit_note}." diff --git a/koan/tests/test_add_project_skill.py b/koan/tests/test_add_project_skill.py index fa5355b60..7728bb436 100644 --- a/koan/tests/test_add_project_skill.py +++ b/koan/tests/test_add_project_skill.py @@ -169,27 +169,27 @@ def test_invalid_url(self, handler): class TestCheckPushAccess: @patch("app.github.run_gh", return_value="ADMIN") def test_admin_has_push(self, mock_gh, handler): - assert handler._check_push_access("owner", "repo") is True + assert handler._check_push_access("github.com", "owner", "repo") is True @patch("app.github.run_gh", return_value="WRITE") def test_write_has_push(self, mock_gh, handler): - assert handler._check_push_access("owner", "repo") is True + assert handler._check_push_access("github.com", "owner", "repo") is True @patch("app.github.run_gh", return_value="MAINTAIN") def test_maintain_has_push(self, mock_gh, handler): - assert handler._check_push_access("owner", "repo") is True + assert handler._check_push_access("github.com", "owner", "repo") is True @patch("app.github.run_gh", return_value="READ") def test_read_no_push(self, mock_gh, handler): - assert handler._check_push_access("owner", "repo") is False + assert handler._check_push_access("github.com", "owner", "repo") is False @patch("app.github.run_gh", return_value="") def test_empty_no_push(self, mock_gh, handler): - assert handler._check_push_access("owner", "repo") is False + assert handler._check_push_access("github.com", "owner", "repo") is False @patch("app.github.run_gh", return_value=" write \n") def test_whitespace_stripped(self, mock_gh, handler): - assert handler._check_push_access("owner", "repo") is True + assert handler._check_push_access("github.com", "owner", "repo") is True # =========================================================================== @@ -224,7 +224,7 @@ def test_success(self, mock_gh, handler, tmp_path): ] with patch.object(handler, "run_git_strict") as mock_git: - result = handler._create_fork_and_configure("owner", "repo", str(project_dir)) + result = handler._create_fork_and_configure("github.com", "owner", "repo", str(project_dir)) assert result == "koan-bot/repo" assert mock_git.call_count == 2 # rename + add @@ -240,7 +240,7 @@ def test_fork_already_exists_continues(self, mock_gh, handler, tmp_path): ] with patch.object(handler, "run_git_strict"): - result = handler._create_fork_and_configure("owner", "repo", str(project_dir)) + result = handler._create_fork_and_configure("github.com", "owner", "repo", str(project_dir)) assert result == "koan-bot/repo" @@ -253,7 +253,7 @@ def test_fork_real_error_raises(self, mock_gh, handler, tmp_path): with patch.object(handler, "run_git_strict"): with pytest.raises(RuntimeError, match="permission denied"): - handler._create_fork_and_configure("owner", "repo", str(project_dir)) + handler._create_fork_and_configure("github.com", "owner", "repo", str(project_dir)) @patch("app.github.run_gh") def test_no_username_raises(self, mock_gh, handler, tmp_path): @@ -268,7 +268,7 @@ def test_no_username_raises(self, mock_gh, handler, tmp_path): with patch.object(handler, "_get_gh_username", return_value=None): with patch.object(handler, "run_git_strict"): with pytest.raises(RuntimeError, match="Cannot determine"): - handler._create_fork_and_configure("owner", "repo", str(project_dir)) + handler._create_fork_and_configure("github.com", "owner", "repo", str(project_dir)) # =========================================================================== @@ -474,15 +474,15 @@ def test_clone_failure_with_push_access(self, handler, ctx): class TestCheckPushAccessSafe: @patch("app.github.run_gh", return_value="WRITE") def test_success(self, mock_gh, handler): - assert handler._check_push_access_safe("owner", "repo") is True + assert handler._check_push_access_safe("github.com", "owner", "repo") is True @patch("app.github.run_gh", return_value="READ") def test_no_access(self, mock_gh, handler): - assert handler._check_push_access_safe("owner", "repo") is False + assert handler._check_push_access_safe("github.com", "owner", "repo") is False @patch("app.github.run_gh", side_effect=RuntimeError("network")) def test_retries_on_failure(self, mock_gh, handler): - result = handler._check_push_access_safe("owner", "repo") + result = handler._check_push_access_safe("github.com", "owner", "repo") assert result is False # Called twice (initial + retry) assert mock_gh.call_count == 2 @@ -493,7 +493,7 @@ def test_succeeds_on_retry(self, mock_gh, handler): RuntimeError("timeout"), # first attempt fails "ADMIN", # retry succeeds ] - assert handler._check_push_access_safe("owner", "repo") is True + assert handler._check_push_access_safe("github.com", "owner", "repo") is True # =========================================================================== @@ -504,7 +504,7 @@ def test_succeeds_on_retry(self, mock_gh, handler): class TestGitClone: def test_clones_via_gh(self, handler): with patch("app.github.run_gh") as mock_gh: - handler._git_clone("https://github.com/owner/repo.git", "/tmp/target") + handler._git_clone("github.com", "https://github.com/owner/repo.git", "/tmp/target") mock_gh.assert_called_once_with( "repo", "clone", "https://github.com/owner/repo.git", "/tmp/target", @@ -514,4 +514,4 @@ def test_clones_via_gh(self, handler): def test_propagates_error(self, handler): with patch("app.github.run_gh", side_effect=RuntimeError("boom")): with pytest.raises(RuntimeError, match="boom"): - handler._git_clone("url", "/tmp/t") + handler._git_clone("github.com", "url", "/tmp/t") diff --git a/koan/tests/test_auto_update.py b/koan/tests/test_auto_update.py index adc7e42b6..dae0a7df1 100644 --- a/koan/tests/test_auto_update.py +++ b/koan/tests/test_auto_update.py @@ -113,6 +113,8 @@ def test_returns_commit_count(self): def mock_git(args, cwd): if args[0] == "fetch": return MagicMock(returncode=0) + if args[0] == "rev-parse": # ref-existence probes resolve + return MagicMock(returncode=0) if args[0] == "rev-list": return MagicMock(returncode=0, stdout="3\n") return MagicMock(returncode=1, stderr="") @@ -126,6 +128,8 @@ def test_returns_zero_when_up_to_date(self): def mock_git(args, cwd): if args[0] == "fetch": return MagicMock(returncode=0) + if args[0] == "rev-parse": + return MagicMock(returncode=0) if args[0] == "rev-list": return MagicMock(returncode=0, stdout="0\n") return MagicMock(returncode=1, stderr="") @@ -135,6 +139,20 @@ def mock_git(args, cwd): result = check_for_updates("/fake/root") assert result == 0 + def test_no_remote_default_ref_returns_none(self): + """When neither /main nor /master exists, skip the + check gracefully instead of running an ambiguous rev-list.""" + def mock_git(args, cwd): + if args[0] == "fetch": + return MagicMock(returncode=0) + # All ref-existence probes fail (no local/remote default branch). + return MagicMock(returncode=1, stderr="") + + with patch("app.auto_update.find_upstream_remote", return_value="upstream"), \ + patch("app.auto_update._run_git", side_effect=mock_git): + result = check_for_updates("/fake/root") + assert result is None + def test_cache_prevents_rapid_checks(self): """Second call within cache window returns 0 without git ops.""" call_count = 0 @@ -144,6 +162,8 @@ def mock_git(args, cwd): call_count += 1 if args[0] == "fetch": return MagicMock(returncode=0) + if args[0] == "rev-parse": + return MagicMock(returncode=0) if args[0] == "rev-list": return MagicMock(returncode=0, stdout="5\n") return MagicMock(returncode=1, stderr="") @@ -155,12 +175,16 @@ def mock_git(args, cwd): assert first == 5 assert second == 0 # cached, no git call - assert call_count == 2 # only fetch + rev-list from first call + # First call: fetch + rev-parse(local main) + rev-parse(remote main) + # + rev-list = 4 git ops. Second call is fully cached. + assert call_count == 4 def test_rev_list_failure_returns_none(self): def mock_git(args, cwd): if args[0] == "fetch": return MagicMock(returncode=0) + if args[0] == "rev-parse": # refs resolve; the rev-list itself fails + return MagicMock(returncode=0) return MagicMock(returncode=1, stderr="bad ref") with patch("app.auto_update.find_upstream_remote", return_value="upstream"), \ diff --git a/koan/tests/test_branch_limiter.py b/koan/tests/test_branch_limiter.py index c810c123d..61c09fd27 100644 --- a/koan/tests/test_branch_limiter.py +++ b/koan/tests/test_branch_limiter.py @@ -1,8 +1,9 @@ """Tests for koan/app/branch_limiter.py — branch saturation limiter.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch from app.branch_limiter import ( + _get_open_pr_branches, count_pending_branches, ) @@ -69,3 +70,40 @@ def test_github_error_falls_back_to_local(self, mock_local, mock_pr): "/instance", "myapp", "/code/myapp", ["owner/myapp"], "bot", ) assert count == 2 + + +class TestGetOpenPrBranchesForge: + """_get_open_pr_branches routes through the project's forge.""" + + def test_github_forge_iterates_configured_urls(self): + forge = MagicMock() + forge.name = "github" + with patch("app.forge.get_forge", return_value=forge), \ + patch("app.github.list_open_pr_branches", + side_effect=[["koan/a"], ["koan/b"]]) as mock_list: + result = _get_open_pr_branches( + "myapp", "/code/myapp", ["o/r1", "o/r2"], "bot", + ) + assert result == {"koan/a", "koan/b"} + assert mock_list.call_count == 2 + + def test_non_github_forge_uses_repo_slug(self): + """Gogs etc.: resolve the slug from the checkout and ask the forge — + the configured github_urls are ignored on non-GitHub forges.""" + forge = MagicMock() + forge.name = "gogs" + forge.repo_slug.return_value = "alice/repo" + forge.list_open_pr_branches.return_value = ["koan/x", "koan/y"] + with patch("app.forge.get_forge", return_value=forge): + result = _get_open_pr_branches( + "myapp", "/code/myapp", ["ignored-url"], "bot", + ) + assert result == {"koan/x", "koan/y"} + forge.repo_slug.assert_called_once_with("/code/myapp") + forge.list_open_pr_branches.assert_called_once_with( + "alice/repo", "bot", cwd="/code/myapp", + ) + + def test_no_author_returns_empty(self): + # Without an author there's nothing to attribute PRs to. + assert _get_open_pr_branches("myapp", "/code/myapp", ["o/r"], "") == set() diff --git a/koan/tests/test_forge_github.py b/koan/tests/test_forge_github.py index bc3d23cf9..ebdadf6cf 100644 --- a/koan/tests/test_forge_github.py +++ b/koan/tests/test_forge_github.py @@ -210,6 +210,51 @@ def test_empty_output_returns_empty_list(self, _mock_run_gh): assert forge.list_merged_prs(repo="owner/repo") == [] +class TestListOpenPrBranches: + @patch("app.github.list_open_pr_branches", return_value=["koan/a", "koan/b"]) + def test_delegates_to_github_helper(self, mock_helper): + forge = _make_forge() + result = forge.list_open_pr_branches("owner/repo", "bot", cwd="/p") + assert result == ["koan/a", "koan/b"] + mock_helper.assert_called_once_with("owner/repo", "bot", cwd="/p") + + +class TestFindPrForBranch: + @patch("app.github.run_gh") + def test_returns_pr_dict(self, mock_run_gh): + mock_run_gh.return_value = json.dumps({ + "number": 7, "state": "OPEN", "isDraft": True, + "url": "https://github.com/owner/repo/pull/7", + "headRefName": "koan/feat", + }) + forge = _make_forge() + pr = forge.find_pr_for_branch("owner/repo", "koan/feat", cwd="/p") + assert pr["number"] == 7 + assert pr["state"] == "OPEN" + + @patch("app.github.run_gh", side_effect=RuntimeError("no PR found")) + def test_returns_none_on_error(self, _mock_run_gh): + forge = _make_forge() + assert forge.find_pr_for_branch("owner/repo", "koan/feat") is None + + @patch("app.github.run_gh", return_value="not-json") + def test_returns_none_on_bad_json(self, _mock_run_gh): + forge = _make_forge() + assert forge.find_pr_for_branch("owner/repo", "koan/feat") is None + + +class TestRepoSlug: + @patch("app.github.origin_repo", return_value="owner/repo") + def test_delegates_to_origin_repo(self, _mock_origin): + forge = _make_forge() + assert forge.repo_slug("/p") == "owner/repo" + + @patch("app.github.origin_repo", return_value=None) + def test_returns_none_when_unparseable(self, _mock_origin): + forge = _make_forge() + assert forge.repo_slug("/p") is None + + # --------------------------------------------------------------------------- # Issue operations # --------------------------------------------------------------------------- diff --git a/koan/tests/test_forge_gogs.py b/koan/tests/test_forge_gogs.py new file mode 100644 index 000000000..469391757 --- /dev/null +++ b/koan/tests/test_forge_gogs.py @@ -0,0 +1,701 @@ +"""Tests for the Gogs forge implementation. + +Covers: + - gogs_auth.py — env var helpers + - gogs_url_parser.py — URL parsing with a configured host + - forge/gogs.py — GogsForge API operations (HTTP mocked) + - forge/registry.py — gogs registered in FORGE_TYPES + - forge/__init__.py — detect_forge_from_url picks GogsForge +""" + +import json +import urllib.error +import urllib.request +from io import BytesIO +from unittest.mock import MagicMock, patch + +import pytest + +from app.forge.gogs import GogsForge, _normalise_pr, _split_repo + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def forge(monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + monkeypatch.setenv("KOAN_GOGS_TOKEN", "test-token-abc") + return GogsForge() + + +def _mock_response(data, status=200): + """Return a mock urllib response with JSON body.""" + body = json.dumps(data).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + +# --------------------------------------------------------------------------- +# gogs_auth +# --------------------------------------------------------------------------- + +class TestGogsAuth: + def test_get_gogs_host_reads_env(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com/") + from app.gogs_auth import get_gogs_host + assert get_gogs_host() == "https://git.example.com" + + def test_get_gogs_host_empty_when_unset(self, monkeypatch): + monkeypatch.delenv("KOAN_GOGS_HOST", raising=False) + from app.gogs_auth import get_gogs_host + assert get_gogs_host() == "" + + def test_get_gogs_token_reads_env(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_TOKEN", "tok123") + from app.gogs_auth import get_gogs_token + assert get_gogs_token() == "tok123" + + def test_get_gogs_token_empty_when_unset(self, monkeypatch): + monkeypatch.delenv("KOAN_GOGS_TOKEN", raising=False) + from app.gogs_auth import get_gogs_token + assert get_gogs_token() == "" + + def test_get_gogs_auth_headers_with_token(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_TOKEN", "mytoken") + from app.gogs_auth import get_gogs_auth_headers + headers = get_gogs_auth_headers() + assert headers == {"Authorization": "token mytoken"} + + def test_get_gogs_auth_headers_empty_without_token(self, monkeypatch): + monkeypatch.delenv("KOAN_GOGS_TOKEN", raising=False) + from app.gogs_auth import get_gogs_auth_headers + assert get_gogs_auth_headers() == {} + + def test_is_gogs_configured_true(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + monkeypatch.setenv("KOAN_GOGS_TOKEN", "tok") + from app.gogs_auth import is_gogs_configured + assert is_gogs_configured() is True + + def test_is_gogs_configured_false_missing_host(self, monkeypatch): + monkeypatch.delenv("KOAN_GOGS_HOST", raising=False) + monkeypatch.setenv("KOAN_GOGS_TOKEN", "tok") + from app.gogs_auth import is_gogs_configured + assert is_gogs_configured() is False + + def test_is_gogs_configured_false_missing_token(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + monkeypatch.delenv("KOAN_GOGS_TOKEN", raising=False) + from app.gogs_auth import is_gogs_configured + assert is_gogs_configured() is False + + +# --------------------------------------------------------------------------- +# gogs_url_parser +# --------------------------------------------------------------------------- + +class TestGogsUrlParser: + def test_parse_pr_url(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.gogs_url_parser import parse_pr_url + owner, repo, number = parse_pr_url("https://git.example.com/alice/myrepo/pulls/42") + assert owner == "alice" + assert repo == "myrepo" + assert number == "42" + + def test_parse_pr_url_invalid_raises(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.gogs_url_parser import parse_pr_url + with pytest.raises(ValueError): + parse_pr_url("https://github.com/owner/repo/pull/1") + + def test_parse_issue_url(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.gogs_url_parser import parse_issue_url + owner, repo, number = parse_issue_url("https://git.example.com/alice/myrepo/issues/7") + assert owner == "alice" + assert repo == "myrepo" + assert number == "7" + + def test_search_pr_url_finds_embedded_url(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.gogs_url_parser import search_pr_url + text = "See PR at https://git.example.com/alice/repo/pulls/10 for details" + owner, repo, number = search_pr_url(text) + assert number == "10" + + def test_search_pr_url_raises_when_not_found(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.gogs_url_parser import search_pr_url + with pytest.raises(ValueError): + search_pr_url("no url here") + + def test_search_issue_url_finds_embedded_url(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.gogs_url_parser import search_issue_url + text = "Fixes https://git.example.com/alice/repo/issues/3" + _, _, number = search_issue_url(text) + assert number == "3" + + def test_parse_pr_url_raises_when_host_not_configured(self, monkeypatch): + monkeypatch.delenv("KOAN_GOGS_HOST", raising=False) + from app.gogs_url_parser import parse_pr_url + with pytest.raises(ValueError, match="KOAN_GOGS_HOST"): + parse_pr_url("https://git.example.com/alice/repo/pulls/1") + + def test_build_pr_url(self): + from app.gogs_url_parser import build_pr_url + url = build_pr_url("https://git.example.com", "alice", "myrepo", 5) + assert url == "https://git.example.com/alice/myrepo/pulls/5" + + def test_build_issue_url(self): + from app.gogs_url_parser import build_issue_url + url = build_issue_url("https://git.example.com", "alice", "myrepo", 3) + assert url == "https://git.example.com/alice/myrepo/issues/3" + + def test_is_gogs_url_true(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.gogs_url_parser import is_gogs_url + assert is_gogs_url("https://git.example.com/alice/repo/pulls/1") is True + + def test_is_gogs_url_false_for_other_host(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.gogs_url_parser import is_gogs_url + assert is_gogs_url("https://github.com/alice/repo/pull/1") is False + + +# --------------------------------------------------------------------------- +# GogsForge — init and meta +# --------------------------------------------------------------------------- + +class TestGogsForgeInit: + def test_name_attribute(self): + assert GogsForge.name == "gogs" + + def test_base_url_from_arg(self, monkeypatch): + monkeypatch.delenv("KOAN_GOGS_HOST", raising=False) + forge = GogsForge(base_url="https://git.myorg.com") + assert forge.base_url == "https://git.myorg.com" + + def test_base_url_from_env(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com/") + forge = GogsForge() + assert forge.base_url == "https://git.example.com" + + def test_trailing_slash_stripped(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com/") + forge = GogsForge() + assert not forge.base_url.endswith("/") + + def test_cli_name(self, forge): + assert forge.cli_name() == "gogs" + + def test_supported_features_include_pr_and_issues(self, forge): + from app.forge.base import FEATURE_ISSUES, FEATURE_PR + assert forge.supports(FEATURE_PR) + assert forge.supports(FEATURE_ISSUES) + + def test_unsupported_features(self, forge): + from app.forge.base import ( + FEATURE_CI_STATUS, + FEATURE_NOTIFICATIONS, + FEATURE_REACTIONS, + ) + assert not forge.supports(FEATURE_CI_STATUS) + assert not forge.supports(FEATURE_NOTIFICATIONS) + assert not forge.supports(FEATURE_REACTIONS) + + def test_auth_env_returns_host_and_token(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + monkeypatch.setenv("KOAN_GOGS_TOKEN", "tok123") + forge = GogsForge() + env = forge.auth_env() + assert env["KOAN_GOGS_HOST"] == "https://git.example.com" + assert env["KOAN_GOGS_TOKEN"] == "tok123" + + +# --------------------------------------------------------------------------- +# GogsForge — requires host +# --------------------------------------------------------------------------- + +class TestGogsForgeRequiresHost: + def test_api_raises_when_host_not_configured(self, monkeypatch): + monkeypatch.delenv("KOAN_GOGS_HOST", raising=False) + forge = GogsForge() + with pytest.raises(RuntimeError, match="KOAN_GOGS_HOST"): + forge._api("GET", "repos/owner/repo/pulls") + + +# --------------------------------------------------------------------------- +# GogsForge — PR operations +# --------------------------------------------------------------------------- + +class TestGogsForge_PR: + def test_pr_create_returns_html_url(self, forge, monkeypatch): + pr_response = { + "number": 42, + "html_url": "https://git.example.com/alice/myrepo/pulls/42", + "title": "My PR", + } + mock_resp = _mock_response(pr_response) + with patch("urllib.request.urlopen", return_value=mock_resp): + url = forge.pr_create( + title="My PR", body="body text", repo="alice/myrepo", head="feature" + ) + assert url == "https://git.example.com/alice/myrepo/pulls/42" + + def test_pr_create_falls_back_to_constructed_url(self, forge, monkeypatch): + pr_response = {"number": 7} # no html_url + mock_resp = _mock_response(pr_response) + with patch("urllib.request.urlopen", return_value=mock_resp): + url = forge.pr_create(title="T", body="B", repo="alice/repo") + assert "pulls/7" in url + + def test_pr_create_raises_on_missing_repo(self, forge): + with pytest.raises(ValueError, match="owner/repo"): + forge.pr_create(title="T", body="B", repo=None) + + def test_pr_view_normalises_fields(self, forge): + raw = { + "number": 3, + "title": "A PR", + "body": "some body", + "state": "open", + "head": {"ref": "feature-branch"}, + "base": {"ref": "main"}, + "html_url": "https://git.example.com/alice/repo/pulls/3", + } + mock_resp = _mock_response(raw) + with patch("urllib.request.urlopen", return_value=mock_resp): + result = forge.pr_view("alice/repo", 3) + assert result["headRefName"] == "feature-branch" + assert result["baseRefName"] == "main" + assert result["url"] == raw["html_url"] + + def test_list_merged_prs_filters_merged(self, forge): + pulls = [ + {"merged": True, "head": {"ref": "feat/done"}}, + {"merged": False, "head": {"ref": "feat/open"}}, + {"merged": True, "head": {"ref": "fix/also-done"}}, + ] + mock_resp = _mock_response(pulls) + with patch("urllib.request.urlopen", return_value=mock_resp): + branches = forge.list_merged_prs("alice/repo") + assert "feat/done" in branches + assert "fix/also-done" in branches + assert "feat/open" not in branches + + def test_list_merged_prs_returns_empty_on_bad_response(self, forge): + mock_resp = _mock_response({}) # not a list + with patch("urllib.request.urlopen", return_value=mock_resp): + branches = forge.list_merged_prs("alice/repo") + assert branches == [] + + def test_list_open_pr_branches_returns_head_refs(self, forge): + pulls = [ + {"head": {"ref": "koan/a"}, "user": {"login": "bot"}}, + {"head": {"ref": "koan/b"}, "user": {"login": "bot"}}, + ] + mock_resp = _mock_response(pulls) + with patch("urllib.request.urlopen", return_value=mock_resp): + branches = forge.list_open_pr_branches("alice/repo") + assert branches == ["koan/a", "koan/b"] + + def test_list_open_pr_branches_filters_by_author(self, forge): + pulls = [ + {"head": {"ref": "koan/a"}, "user": {"login": "bot"}}, + {"head": {"ref": "human/x"}, "user": {"login": "alice"}}, + ] + mock_resp = _mock_response(pulls) + with patch("urllib.request.urlopen", return_value=mock_resp): + branches = forge.list_open_pr_branches("alice/repo", author="bot") + assert branches == ["koan/a"] + + def test_list_open_pr_branches_empty_on_error(self, forge): + import urllib.error + with patch( + "urllib.request.urlopen", + side_effect=urllib.error.URLError("boom"), + ): + assert forge.list_open_pr_branches("alice/repo") == [] + + def test_find_pr_for_branch_returns_normalised_open_pr(self, forge): + pulls = [ + {"number": 9, "state": "open", "merged": False, + "head": {"ref": "koan/feat"}, + "html_url": "https://git.example.com/alice/repo/pulls/9"}, + ] + mock_resp = _mock_response(pulls) + with patch("urllib.request.urlopen", return_value=mock_resp): + pr = forge.find_pr_for_branch("alice/repo", "koan/feat") + assert pr["number"] == 9 + assert pr["state"] == "OPEN" + assert pr["isDraft"] is False + assert pr["url"].endswith("/pulls/9") + + def test_find_pr_for_branch_maps_merged_state(self, forge): + pulls = [ + {"number": 4, "state": "closed", "merged": True, + "head": {"ref": "koan/done"}, "html_url": "u"}, + ] + mock_resp = _mock_response(pulls) + with patch("urllib.request.urlopen", return_value=mock_resp): + pr = forge.find_pr_for_branch("alice/repo", "koan/done") + assert pr["state"] == "MERGED" + + def test_find_pr_for_branch_returns_none_when_absent(self, forge): + pulls = [{"number": 1, "state": "open", "head": {"ref": "other"}}] + mock_resp = _mock_response(pulls) + with patch("urllib.request.urlopen", return_value=mock_resp): + assert forge.find_pr_for_branch("alice/repo", "koan/missing") is None + + def test_repo_slug_parses_origin_remote(self, forge, monkeypatch): + monkeypatch.setattr( + "app.forge.gogs._owner_repo_from_git_remote", + lambda path: ("alice", "repo"), + ) + assert forge.repo_slug("/p") == "alice/repo" + + def test_repo_slug_none_when_no_remote(self, forge, monkeypatch): + monkeypatch.setattr( + "app.forge.gogs._owner_repo_from_git_remote", + lambda path: None, + ) + assert forge.repo_slug("/p") is None + + +# --------------------------------------------------------------------------- +# GogsForge — issue operations +# --------------------------------------------------------------------------- + +class TestGogsForge_Issues: + def test_issue_create_in_repo_returns_url(self, forge): + response = { + "number": 5, + "html_url": "https://git.example.com/alice/repo/issues/5", + } + mock_resp = _mock_response(response) + with patch("urllib.request.urlopen", return_value=mock_resp): + url = forge.issue_create_in_repo("alice/repo", "Bug found", "details") + assert url == "https://git.example.com/alice/repo/issues/5" + + def test_issue_create_raises_on_missing_cwd(self, forge): + with pytest.raises(RuntimeError, match="not a git repository"): + forge.issue_create("title", "body") + + def test_issue_create_raises_when_repo_unresolvable(self, forge): + # issue_create derives the repo from the git remote in ``cwd``; with + # no cwd (and thus no resolvable remote) it cannot determine where to + # file the issue and raises RuntimeError. Gogs *does* support issues + # (see test_supported_features_include_pr_and_issues), so this is no + # longer a NotImplementedError. + with pytest.raises(RuntimeError): + forge.issue_create("title", "body") + + +# --------------------------------------------------------------------------- +# GogsForge — API passthrough +# --------------------------------------------------------------------------- + +class TestGogsForge_API: + def test_run_api_returns_json_string(self, forge): + data = {"key": "value"} + mock_resp = _mock_response(data) + with patch("urllib.request.urlopen", return_value=mock_resp): + result = forge.run_api("repos/alice/repo/issues") + assert json.loads(result) == data + + def test_api_raises_runtime_error_on_http_error(self, forge): + exc = urllib.error.HTTPError( + url="http://x", code=404, msg="Not Found", hdrs=None, fp=None + ) + with patch("urllib.request.urlopen", side_effect=exc): + with pytest.raises(RuntimeError, match="HTTP 404"): + forge._api("GET", "repos/nobody/nope") + + +# --------------------------------------------------------------------------- +# GogsForge — URL construction +# --------------------------------------------------------------------------- + +class TestGogsForge_WebUrl: + def test_get_web_url_for_pr(self, forge): + url = forge.get_web_url("alice/repo", "pr", 10) + assert url == "https://git.example.com/alice/repo/pulls/10" + + def test_get_web_url_for_issue(self, forge): + url = forge.get_web_url("alice/repo", "issue", 3) + assert url == "https://git.example.com/alice/repo/issues/3" + + def test_get_web_url_for_pulls_type(self, forge): + url = forge.get_web_url("alice/repo", "pulls", 5) + assert url == "https://git.example.com/alice/repo/pulls/5" + + +# --------------------------------------------------------------------------- +# GogsForge — URL parsing delegation +# --------------------------------------------------------------------------- + +class TestGogsForge_UrlParsing: + def test_parse_pr_url(self, forge, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + owner, repo, number = forge.parse_pr_url( + "https://git.example.com/alice/myrepo/pulls/42" + ) + assert owner == "alice" + assert repo == "myrepo" + assert number == "42" + + def test_parse_issue_url(self, forge, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + owner, repo, number = forge.parse_issue_url( + "https://git.example.com/alice/myrepo/issues/7" + ) + assert number == "7" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class TestSplitRepo: + def test_valid_owner_repo(self): + owner, repo = _split_repo("alice/myrepo") + assert owner == "alice" + assert repo == "myrepo" + + def test_raises_on_none(self): + with pytest.raises(ValueError): + _split_repo(None) + + def test_raises_on_missing_slash(self): + with pytest.raises(ValueError): + _split_repo("noslash") + + def test_raises_on_empty(self): + with pytest.raises(ValueError): + _split_repo("") + + +class TestNormalisePr: + def test_maps_head_ref(self): + raw = {"head": {"ref": "feature"}, "base": {"ref": "main"}, "number": 1, + "title": "t", "body": "b", "state": "open", "html_url": "http://x/1"} + result = _normalise_pr(raw) + assert result["headRefName"] == "feature" + assert result["baseRefName"] == "main" + + def test_handles_missing_head(self): + result = _normalise_pr({}) + assert result["headRefName"] == "" + assert result["baseRefName"] == "" + + +# --------------------------------------------------------------------------- +# Registry — gogs is registered +# --------------------------------------------------------------------------- + +class TestGogsInRegistry: + def test_gogs_in_forge_types(self): + from app.forge.registry import FORGE_TYPES + assert "gogs" in FORGE_TYPES + + def test_gogs_forge_class_registered(self): + from app.forge.registry import FORGE_TYPES + assert FORGE_TYPES["gogs"] is GogsForge + + def test_get_forge_class_gogs(self): + from app.forge.registry import get_forge_class + cls = get_forge_class("gogs") + assert cls is GogsForge + + def test_all_forge_types_are_subclasses(self): + from app.forge.base import ForgeProvider + from app.forge.registry import FORGE_TYPES + for name, cls in FORGE_TYPES.items(): + assert issubclass(cls, ForgeProvider), ( + f"FORGE_TYPES[{name!r}] is not a ForgeProvider subclass" + ) + + +# --------------------------------------------------------------------------- +# Factory — detect_forge_from_url picks GogsForge for configured host +# --------------------------------------------------------------------------- + +class TestDetectForgeFromUrlGogs: + def test_gogs_url_returns_gogs_forge(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.forge import detect_forge_from_url + forge = detect_forge_from_url("https://git.example.com/alice/repo/pulls/1") + assert isinstance(forge, GogsForge) + + def test_github_url_still_returns_github_forge(self, monkeypatch): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + from app.forge import detect_forge_from_url + from app.forge.github import GitHubForge + forge = detect_forge_from_url("https://github.com/alice/repo/pull/1") + assert isinstance(forge, GitHubForge) + + def test_gogs_not_detected_when_host_not_configured(self, monkeypatch): + monkeypatch.delenv("KOAN_GOGS_HOST", raising=False) + from app.forge import detect_forge_from_url + from app.forge.github import GitHubForge + # Falls back to GitHub when KOAN_GOGS_HOST not set + forge = detect_forge_from_url("https://git.example.com/alice/repo/pulls/1") + assert isinstance(forge, GitHubForge) + + +# --------------------------------------------------------------------------- +# get_forge — returns GogsForge when configured in projects.yaml +# --------------------------------------------------------------------------- + +class TestGetForgeGogs: + def test_get_forge_returns_gogs_for_configured_project(self, monkeypatch, tmp_path): + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + monkeypatch.setenv("KOAN_GOGS_TOKEN", "tok") + monkeypatch.setenv("KOAN_ROOT", str(tmp_path)) + + projects_yaml = tmp_path / "projects.yaml" + projects_yaml.write_text( + "projects:\n" + " my-gogs-project:\n" + " path: /tmp/my-gogs-project\n" + " forge: gogs\n" + " forge_url: https://git.example.com\n" + ) + + from app.forge import get_forge + forge = get_forge("my-gogs-project") + assert isinstance(forge, GogsForge) + + +# --------------------------------------------------------------------------- +# scripts/gogs CLI — repo permissions and fork commands +# --------------------------------------------------------------------------- + +import importlib.machinery +import importlib.util +import pathlib +import sys + + +def _load_gogs_script(monkeypatch): + """Load scripts/gogs as a module, with env vars set.""" + monkeypatch.setenv("KOAN_GOGS_HOST", "https://git.example.com") + monkeypatch.setenv("KOAN_GOGS_TOKEN", "test-token") + script_path = str(pathlib.Path(__file__).resolve().parent.parent.parent / "scripts" / "gogs") + loader = importlib.machinery.SourceFileLoader("gogs_script", script_path) + spec = importlib.util.spec_from_file_location("gogs_script", script_path, loader=loader) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class TestGogsScriptRepoPermissions: + def test_permissions_outputs_json(self, monkeypatch, capsys): + mod = _load_gogs_script(monkeypatch) + + repo_data = { + "permissions": {"admin": False, "push": True, "pull": True}, + "name": "myrepo", + } + mock_resp = _mock_response(repo_data) + with patch("urllib.request.urlopen", return_value=mock_resp): + mod.cmd_repo_permissions(["--repo", "alice/myrepo"]) + + out = capsys.readouterr().out + result = json.loads(out) + assert result == {"admin": False, "push": True, "pull": True} + + def test_permissions_defaults_falsy_fields(self, monkeypatch, capsys): + mod = _load_gogs_script(monkeypatch) + + # Gogs instance that omits permissions entirely + repo_data = {"name": "myrepo"} + mock_resp = _mock_response(repo_data) + with patch("urllib.request.urlopen", return_value=mock_resp): + mod.cmd_repo_permissions(["--repo", "alice/myrepo"]) + + out = capsys.readouterr().out + result = json.loads(out) + assert result == {"admin": False, "push": False, "pull": False} + + def test_permissions_jq_filter(self, monkeypatch, capsys): + mod = _load_gogs_script(monkeypatch) + + repo_data = {"permissions": {"admin": True, "push": True, "pull": True}} + mock_resp = _mock_response(repo_data) + with patch("urllib.request.urlopen", return_value=mock_resp): + mod.cmd_repo_permissions(["--repo", "alice/myrepo", "--jq", ".admin"]) + + out = capsys.readouterr().out.strip() + assert out == "True" + + def test_permissions_json_field_filter(self, monkeypatch, capsys): + mod = _load_gogs_script(monkeypatch) + + repo_data = {"permissions": {"admin": False, "push": True, "pull": True}} + mock_resp = _mock_response(repo_data) + with patch("urllib.request.urlopen", return_value=mock_resp): + mod.cmd_repo_permissions(["--repo", "alice/myrepo", "--json", "push,pull"]) + + out = capsys.readouterr().out + result = json.loads(out) + assert result == {"push": True, "pull": True} + assert "admin" not in result + + +class TestGogsScriptRepoFork: + def test_fork_returns_html_url(self, monkeypatch, capsys): + mod = _load_gogs_script(monkeypatch) + + fork_data = { + "html_url": "https://git.example.com/bob/myrepo", + "full_name": "bob/myrepo", + } + mock_resp = _mock_response(fork_data) + with patch("urllib.request.urlopen", return_value=mock_resp): + mod.cmd_repo_fork(["--repo", "alice/myrepo"]) + + out = capsys.readouterr().out.strip() + assert out == "https://git.example.com/bob/myrepo" + + def test_fork_with_org(self, monkeypatch, capsys): + mod = _load_gogs_script(monkeypatch) + + fork_data = { + "html_url": "https://git.example.com/myorg/myrepo", + "full_name": "myorg/myrepo", + } + mock_resp = _mock_response(fork_data) + + captured_req = {} + + def _fake_urlopen(req, timeout=30): + captured_req["data"] = req.data + return mock_resp + + with patch("urllib.request.urlopen", side_effect=_fake_urlopen): + mod.cmd_repo_fork(["--repo", "alice/myrepo", "--org", "myorg"]) + + out = capsys.readouterr().out.strip() + assert out == "https://git.example.com/myorg/myrepo" + body = json.loads(captured_req["data"]) + assert body["organization"] == "myorg" + + def test_fork_falls_back_to_full_name(self, monkeypatch, capsys): + mod = _load_gogs_script(monkeypatch) + + # Gogs instance that doesn't return html_url + fork_data = {"full_name": "bob/myrepo"} + mock_resp = _mock_response(fork_data) + with patch("urllib.request.urlopen", return_value=mock_resp): + mod.cmd_repo_fork(["--repo", "alice/myrepo"]) + + out = capsys.readouterr().out.strip() + assert out == "https://git.example.com/bob/myrepo" diff --git a/koan/tests/test_forge_registry.py b/koan/tests/test_forge_registry.py index 697f9a157..c71e60db1 100644 --- a/koan/tests/test_forge_registry.py +++ b/koan/tests/test_forge_registry.py @@ -67,6 +67,31 @@ def test_returns_github_forge_for_unconfigured_project(self): assert isinstance(forge, GitHubForge) +class TestGetForgeForPath: + """Tests for the get_forge_for_path() convenience wrapper.""" + + def test_derives_project_name_from_basename(self): + from app.forge import get_forge_for_path + import app.forge as forge_pkg + from unittest.mock import patch + with patch.object(forge_pkg, "get_forge") as mock_get_forge: + get_forge_for_path("/home/koan/workspace/my-toolkit") + mock_get_forge.assert_called_once_with("my-toolkit") + + def test_strips_trailing_slash(self): + from app.forge import get_forge_for_path + import app.forge as forge_pkg + from unittest.mock import patch + with patch.object(forge_pkg, "get_forge") as mock_get_forge: + get_forge_for_path("/home/koan/workspace/proj/") + mock_get_forge.assert_called_once_with("proj") + + def test_unconfigured_project_returns_github(self): + from app.forge import get_forge_for_path + forge = get_forge_for_path("/tmp/project-that-does-not-exist") + assert isinstance(forge, GitHubForge) + + class TestDetectForgeFromUrl: def test_github_url_returns_github_forge(self): from app.forge import detect_forge_from_url diff --git a/koan/tests/test_git_sync.py b/koan/tests/test_git_sync.py index ac32ff102..90244ca06 100644 --- a/koan/tests/test_git_sync.py +++ b/koan/tests/test_git_sync.py @@ -422,6 +422,27 @@ def test_returns_empty_on_os_error(self): with patch(_RUN_GH_PATH, side_effect=OSError("not found")): assert _sync().get_github_merged_branches() == [] + def test_non_github_forge_uses_forge_list_merged(self): + """On a non-GitHub forge, merged detection routes through the forge's + list_merged_prs (slug resolved from the checkout), still filtered by + the agent prefix — never touching the GitHub `gh` path.""" + forge = MagicMock() + forge.name = "gogs" + forge.repo_slug.return_value = "alice/repo" + forge.list_merged_prs.return_value = ["koan/done", "feature/other"] + with patch("app.forge.get_forge", return_value=forge), \ + patch(_RUN_GH_PATH, side_effect=AssertionError("gh must not be called")): + branches = _sync().get_github_merged_branches() + assert branches == ["koan/done"] + forge.list_merged_prs.assert_called_once_with("alice/repo", cwd="/fake") + + def test_non_github_forge_empty_when_no_slug(self): + forge = MagicMock() + forge.name = "gogs" + forge.repo_slug.return_value = None + with patch("app.forge.get_forge", return_value=forge): + assert _sync().get_github_merged_branches() == [] + class TestCleanupWithGithubMerged: """Tests for cleanup_merged_branches with github_merged parameter.""" diff --git a/koan/tests/test_mission_verifier.py b/koan/tests/test_mission_verifier.py index 3ebab4741..8d625b094 100644 --- a/koan/tests/test_mission_verifier.py +++ b/koan/tests/test_mission_verifier.py @@ -231,6 +231,25 @@ def test_warn_pr_not_open(self, mock_gh, mock_git): assert result.status == CheckStatus.WARN assert "CLOSED" in result.message + @patch("app.mission_verifier.run_git") + def test_pass_via_non_github_forge(self, mock_git): + """The PR lookup routes through the project's forge — a Gogs project + resolves its PR via the forge API, never `gh`.""" + from unittest.mock import MagicMock + mock_git.return_value = (0, "koan/my-branch", "") + forge = MagicMock() + forge.repo_slug.return_value = "alice/repo" + forge.find_pr_for_branch.return_value = { + "number": 7, "state": "OPEN", "isDraft": False, + } + with patch("app.forge.get_forge_for_path", return_value=forge): + result = check_pr_created("/project", "implement login") + assert result.status == CheckStatus.PASS + assert "#7" in result.message + forge.find_pr_for_branch.assert_called_once_with( + "alice/repo", "koan/my-branch", cwd="/project", + ) + # --------------------------------------------------------------------------- # check_commit_quality diff --git a/koan/tests/test_pr_submit.py b/koan/tests/test_pr_submit.py index af537e067..f9c6e36cc 100644 --- a/koan/tests/test_pr_submit.py +++ b/koan/tests/test_pr_submit.py @@ -249,6 +249,40 @@ def test_creates_pr_with_correct_kwargs(self): assert kw["body"] == "## Summary\nFixed." assert kw["draft"] is True + def test_non_github_forge_creates_pr_via_forge(self): + """On a non-GitHub forge the existing-PR check and PR creation go + through the forge API (not `gh`), so Gogs projects can actually open + PRs instead of stalling at 'open via web UI'.""" + forge = MagicMock() + forge.name = "gogs" + forge.repo_slug.return_value = "alice/repo" + forge.find_pr_for_branch.return_value = None # no existing PR + forge.pr_create.return_value = "https://git.example.com/alice/repo/pulls/3" + with patch(f"{_M}.get_current_branch", return_value="koan/feat"), \ + patch(f"{_M}.resolve_base_branch", return_value="main"), \ + patch(f"{_M}.get_commit_subjects", return_value=["c1"]), \ + patch(f"{_M}.run_git_strict"), \ + patch(f"{_M}.resolve_submit_target", + return_value={"repo": "alice/repo", "is_fork": False}), \ + patch("app.forge.get_forge", return_value=forge): + result = submit_draft_pr("/p", "proj", "alice", "repo", "1", "T", "B") + assert result == "https://git.example.com/alice/repo/pulls/3" + forge.pr_create.assert_called_once() + + def test_non_github_forge_returns_existing_open_pr(self): + forge = MagicMock() + forge.name = "gogs" + forge.repo_slug.return_value = "alice/repo" + forge.find_pr_for_branch.return_value = { + "state": "OPEN", "url": "https://git.example.com/alice/repo/pulls/9", + } + with patch(f"{_M}.get_current_branch", return_value="koan/feat"), \ + patch(f"{_M}.resolve_base_branch", return_value="main"), \ + patch("app.forge.get_forge", return_value=forge): + result = submit_draft_pr("/p", "proj", "alice", "repo", "1", "T", "B") + assert result == "https://git.example.com/alice/repo/pulls/9" + forge.pr_create.assert_not_called() + def test_fork_workflow_sets_repo_and_head(self): with patch(f"{_M}.get_current_branch", return_value="koan/feat"), \ patch(f"{_M}.resolve_base_branch", return_value="main"), \ diff --git a/koan/tests/test_review_handler.py b/koan/tests/test_review_handler.py index 216d39491..a29f47ac7 100644 --- a/koan/tests/test_review_handler.py +++ b/koan/tests/test_review_handler.py @@ -6,7 +6,12 @@ from skills.core.review.handler import ( handle, _list_open_prs, + _list_gogs_open_prs, _handle_batch, + _handle_gogs_single, + _handle_gogs_batch, + _parse_gogs_repo_url, + _try_extract_gogs_pr_or_issue, ) from app.github_skill_helpers import parse_repo_url, parse_limit from app.skills import SkillContext @@ -290,3 +295,221 @@ def test_without_now_flag_not_urgent(self, mock_single): handle(ctx) assert mock_single.call_args[1].get("urgent", False) is False + + @patch(f"{_HANDLER}._handle_gogs_batch") + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_gogs_repo_url_routes_to_gogs_batch(self, mock_host, mock_gogs_batch): + mock_gogs_batch.return_value = "Queued 2 /review missions for Gogs repo owner/repo" + ctx = self._make_ctx("https://git.example.com/owner/repo") + result = handle(ctx) + + mock_gogs_batch.assert_called_once() + assert result == "Queued 2 /review missions for Gogs repo owner/repo" + + @patch(f"{_HANDLER}._handle_gogs_single") + @patch(f"{_HANDLER}._parse_gogs_repo_url", return_value=None) + @patch("app.gogs_url_parser.search_pr_url") + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_gogs_pr_url_routes_to_gogs_single( + self, mock_host, mock_search, mock_parse_repo, mock_gogs_single + ): + mock_search.return_value = ("owner", "repo", "42") + mock_gogs_single.return_value = "Review queued for Gogs PR #42" + ctx = self._make_ctx("https://git.example.com/owner/repo/pulls/42") + result = handle(ctx) + + mock_gogs_single.assert_called_once() + + +# --------------------------------------------------------------------------- +# _parse_gogs_repo_url +# --------------------------------------------------------------------------- + +class TestParseGogsRepoUrl: + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_plain_gogs_repo_url(self, mock_host): + result = _parse_gogs_repo_url("https://git.example.com/owner/repo") + assert result == ("https://git.example.com/owner/repo", "owner", "repo") + + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_rejects_gogs_pr_url(self, mock_host): + result = _parse_gogs_repo_url("https://git.example.com/owner/repo/pulls/42") + assert result is None + + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_rejects_gogs_issue_url(self, mock_host): + result = _parse_gogs_repo_url("https://git.example.com/owner/repo/issues/5") + assert result is None + + @patch("app.gogs_auth.get_gogs_host", return_value="") + def test_unconfigured_host_returns_none(self, mock_host): + result = _parse_gogs_repo_url("https://git.example.com/owner/repo") + assert result is None + + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_rejects_subpath_names(self, mock_host): + result = _parse_gogs_repo_url("https://git.example.com/owner/pulls") + assert result is None + + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_github_url_not_matched(self, mock_host): + result = _parse_gogs_repo_url("https://github.com/owner/repo") + assert result is None + + +# --------------------------------------------------------------------------- +# _try_extract_gogs_pr_or_issue +# --------------------------------------------------------------------------- + +class TestTryExtractGogsPrOrIssue: + @patch("app.gogs_url_parser.search_pr_url") + def test_extracts_pr(self, mock_search): + mock_search.return_value = ("owner", "repo", "42") + result = _try_extract_gogs_pr_or_issue("https://git.example.com/owner/repo/pulls/42") + assert result == ("owner", "repo", "42", "PR") + + @patch("app.gogs_url_parser.search_pr_url", side_effect=ValueError("no match")) + @patch("app.gogs_url_parser.search_issue_url") + def test_extracts_issue_when_pr_fails(self, mock_issue, mock_pr): + mock_issue.return_value = ("owner", "repo", "5") + result = _try_extract_gogs_pr_or_issue("https://git.example.com/owner/repo/issues/5") + assert result == ("owner", "repo", "5", "issue") + + @patch("app.gogs_url_parser.search_pr_url", side_effect=ValueError("no match")) + @patch("app.gogs_url_parser.search_issue_url", side_effect=ValueError("no match")) + def test_returns_none_on_no_match(self, mock_issue, mock_pr): + result = _try_extract_gogs_pr_or_issue("https://github.com/owner/repo/pull/42") + assert result is None + + +# --------------------------------------------------------------------------- +# _list_gogs_open_prs +# --------------------------------------------------------------------------- + +class TestListGogsOpenPrs: + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + @patch("app.forge.gogs.GogsForge._api") + def test_returns_pr_dicts(self, mock_api, mock_host): + mock_api.return_value = [ + {"number": 1, "title": "Fix bug", "html_url": "https://git.example.com/o/r/pulls/1"}, + {"number": 2, "title": "Add feature", "html_url": "https://git.example.com/o/r/pulls/2"}, + ] + result = _list_gogs_open_prs("o", "r") + assert len(result) == 2 + assert result[0]["number"] == 1 + assert result[0]["url"] == "https://git.example.com/o/r/pulls/1" + + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + @patch("app.forge.gogs.GogsForge._api") + def test_builds_url_when_html_url_missing(self, mock_api, mock_host): + mock_api.return_value = [ + {"number": 7, "title": "My PR"}, + ] + result = _list_gogs_open_prs("o", "r") + assert result[0]["url"] == "https://git.example.com/o/r/pulls/7" + + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + @patch("app.forge.gogs.GogsForge._api") + def test_non_list_response_returns_empty(self, mock_api, mock_host): + mock_api.return_value = {"error": "not found"} + result = _list_gogs_open_prs("o", "r") + assert result == [] + + +# --------------------------------------------------------------------------- +# _handle_gogs_single +# --------------------------------------------------------------------------- + +class TestHandleGogsSingle: + def _make_ctx(self, args=""): + return SkillContext( + koan_root=Path("/tmp/test"), + instance_dir=Path("/tmp/test/instance"), + command_name="review", + args=args, + ) + + @patch(f"{_HANDLER}.queue_github_mission", return_value=True) + @patch(f"{_HANDLER}.resolve_project_for_repo", return_value=("/path", "myrepo")) + @patch("app.gogs_url_parser.build_pr_url", return_value="https://git.example.com/o/r/pulls/42") + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_queues_pr_review(self, mock_host, mock_build, mock_resolve, mock_queue): + ctx = self._make_ctx() + result = _handle_gogs_single(ctx, "", "o", "r", "42", "PR", False) + assert "Review queued" in result + assert "PR #42" in result + mock_queue.assert_called_once() + + @patch(f"{_HANDLER}.queue_github_mission", return_value=False) + @patch(f"{_HANDLER}.resolve_project_for_repo", return_value=("/path", "myrepo")) + @patch("app.gogs_url_parser.build_pr_url", return_value="https://git.example.com/o/r/pulls/42") + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_duplicate_returns_warning(self, mock_host, mock_build, mock_resolve, mock_queue): + ctx = self._make_ctx() + result = _handle_gogs_single(ctx, "", "o", "r", "42", "PR", False) + assert "Duplicate ignored" in result + + @patch(f"{_HANDLER}.resolve_project_for_repo", return_value=(None, None)) + @patch("app.gogs_url_parser.build_pr_url", return_value="https://git.example.com/o/r/pulls/42") + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_project_not_found(self, mock_host, mock_build, mock_resolve): + ctx = self._make_ctx() + result = _handle_gogs_single(ctx, "", "o", "r", "42", "PR", False) + assert "Could not find" in result + + @patch(f"{_HANDLER}.queue_github_mission", return_value=True) + @patch(f"{_HANDLER}.resolve_project_for_repo", return_value=("/path", "myrepo")) + @patch("app.gogs_url_parser.build_pr_url", return_value="https://git.example.com/o/r/pulls/42") + @patch("app.gogs_auth.get_gogs_host", return_value="https://git.example.com") + def test_urgent_note_in_response(self, mock_host, mock_build, mock_resolve, mock_queue): + ctx = self._make_ctx() + result = _handle_gogs_single(ctx, "", "o", "r", "42", "PR", True) + assert "(priority)" in result + + +# --------------------------------------------------------------------------- +# _handle_gogs_batch +# --------------------------------------------------------------------------- + +class TestHandleGogsBatch: + def _make_ctx(self, args=""): + return SkillContext( + koan_root=Path("/tmp/test"), + instance_dir=Path("/tmp/test/instance"), + command_name="review", + args=args, + ) + + @patch(f"{_HANDLER}.queue_github_mission", return_value=True) + @patch(f"{_HANDLER}._list_gogs_open_prs") + @patch(f"{_HANDLER}.resolve_project_for_repo", return_value=("/path", "myrepo")) + def test_queues_all_prs(self, mock_resolve, mock_list, mock_queue): + mock_list.return_value = [ + {"number": 1, "url": "https://git.example.com/o/r/pulls/1"}, + {"number": 2, "url": "https://git.example.com/o/r/pulls/2"}, + ] + ctx = self._make_ctx() + result = _handle_gogs_batch(ctx, "", ("https://git.example.com/o/r", "o", "r"), False) + assert "2" in result + assert mock_queue.call_count == 2 + + @patch(f"{_HANDLER}._list_gogs_open_prs") + @patch(f"{_HANDLER}.resolve_project_for_repo", return_value=("/path", "myrepo")) + def test_no_prs_message(self, mock_resolve, mock_list): + mock_list.return_value = [] + ctx = self._make_ctx() + result = _handle_gogs_batch(ctx, "", ("https://git.example.com/o/r", "o", "r"), False) + assert "No open PRs" in result + + @patch(f"{_HANDLER}._list_gogs_open_prs", side_effect=RuntimeError("API error")) + @patch(f"{_HANDLER}.resolve_project_for_repo", return_value=("/path", "myrepo")) + def test_api_error_message(self, mock_resolve, mock_list): + ctx = self._make_ctx() + result = _handle_gogs_batch(ctx, "", ("https://git.example.com/o/r", "o", "r"), False) + assert "Failed to list Gogs PRs" in result + + @patch(f"{_HANDLER}.resolve_project_for_repo", return_value=(None, None)) + def test_project_not_found(self, mock_resolve): + ctx = self._make_ctx() + result = _handle_gogs_batch(ctx, "", ("https://git.example.com/o/r", "o", "r"), False) + assert "Could not find" in result diff --git a/koan/tests/test_skill_add_project.py b/koan/tests/test_skill_add_project.py index 78fd521df..0c02fab8e 100644 --- a/koan/tests/test_skill_add_project.py +++ b/koan/tests/test_skill_add_project.py @@ -238,7 +238,7 @@ def test_clone_with_push( self, mock_refresh, mock_push, mock_clone, koan_root, instance_dir, workspace_dir ): - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -256,7 +256,7 @@ def test_clone_with_custom_name( self, mock_refresh, mock_push, mock_clone, koan_root, instance_dir, workspace_dir ): - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -274,7 +274,7 @@ def test_creates_workspace_dir_if_missing( koan_root, instance_dir ): """workspace/ is created automatically if it doesn't exist.""" - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -298,7 +298,7 @@ def test_fork_when_no_push( self, mock_refresh, mock_fork, mock_push, mock_clone, koan_root, instance_dir, workspace_dir ): - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -319,7 +319,7 @@ def test_fork_failure_still_adds_project( koan_root, instance_dir, workspace_dir ): """If fork creation fails, the project is still registered.""" - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -338,7 +338,7 @@ def test_push_check_exception_triggers_fork( koan_root, instance_dir, workspace_dir ): """If push check raises, treat as no access.""" - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -360,7 +360,7 @@ def test_refresh_projects_called( self, mock_refresh, mock_push, mock_clone, koan_root, instance_dir, workspace_dir ): - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -376,7 +376,7 @@ def test_refresh_failure_does_not_crash( self, mock_refresh, mock_push, mock_clone, koan_root, instance_dir, workspace_dir ): - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -398,7 +398,7 @@ def test_cloning_message_sent( self, mock_refresh, mock_push, mock_clone, koan_root, instance_dir, workspace_dir ): - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -415,7 +415,7 @@ def test_fork_message_sent( self, mock_refresh, mock_fork, mock_push, mock_clone, koan_root, instance_dir, workspace_dir ): - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -432,23 +432,23 @@ def fake_clone(url, target): class TestCheckPushAccess: @patch("app.github.run_gh", return_value="ADMIN") def test_admin_returns_true(self, _): - assert _mod._check_push_access("owner", "repo") is True + assert _mod._check_push_access("github.com", "owner", "repo") is True @patch("app.github.run_gh", return_value="WRITE") def test_write_returns_true(self, _): - assert _mod._check_push_access("owner", "repo") is True + assert _mod._check_push_access("github.com", "owner", "repo") is True @patch("app.github.run_gh", return_value="MAINTAIN") def test_maintain_returns_true(self, _): - assert _mod._check_push_access("owner", "repo") is True + assert _mod._check_push_access("github.com", "owner", "repo") is True @patch("app.github.run_gh", return_value="READ") def test_read_returns_false(self, _): - assert _mod._check_push_access("owner", "repo") is False + assert _mod._check_push_access("github.com", "owner", "repo") is False @patch("app.github.run_gh", return_value="") def test_none_returns_false(self, _): - assert _mod._check_push_access("owner", "repo") is False + assert _mod._check_push_access("github.com", "owner", "repo") is False # --------------------------------------------------------------------------- @@ -461,7 +461,7 @@ class TestCreateForkAndConfigure: @patch(f"{P}.run_git_strict") def test_creates_fork_and_reconfigures(self, mock_git, mock_gh, mock_user): result = _mod._create_fork_and_configure( - "upstream-owner", "repo", "/tmp/project" + "github.com", "upstream-owner", "repo", "/tmp/project" ) assert result == "myuser/repo" @@ -479,7 +479,7 @@ def test_creates_fork_and_reconfigures(self, mock_git, mock_gh, mock_user): @patch(f"{P}.run_git_strict") def test_fork_already_exists_is_ok(self, mock_git, mock_gh, mock_user): result = _mod._create_fork_and_configure( - "upstream-owner", "repo", "/tmp/project" + "github.com", "upstream-owner", "repo", "/tmp/project" ) assert result == "myuser/repo" @@ -488,7 +488,7 @@ def test_fork_already_exists_is_ok(self, mock_git, mock_gh, mock_user): def test_no_username_raises(self, mock_gh, mock_user): with pytest.raises(RuntimeError, match="Cannot determine"): _mod._create_fork_and_configure( - "upstream-owner", "repo", "/tmp/project" + "github.com", "upstream-owner", "repo", "/tmp/project" ) @patch(f"{P}._get_gh_username", return_value="myuser") @@ -496,7 +496,7 @@ def test_no_username_raises(self, mock_gh, mock_user): def test_fork_api_error_raises(self, mock_gh, mock_user): with pytest.raises(RuntimeError, match="forbidden"): _mod._create_fork_and_configure( - "upstream-owner", "repo", "/tmp/project" + "github.com", "upstream-owner", "repo", "/tmp/project" ) @@ -512,7 +512,7 @@ def test_name_from_repo( self, mock_refresh, mock_push, mock_clone, koan_root, instance_dir, workspace_dir ): - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -529,7 +529,7 @@ def test_name_from_url_with_git_suffix( self, mock_refresh, mock_push, mock_clone, koan_root, instance_dir, workspace_dir ): - def fake_clone(url, target): + def fake_clone(host, url, target): Path(target).mkdir(parents=True) mock_clone.side_effect = fake_clone @@ -555,7 +555,7 @@ def test_correct_clone_url( ): clone_urls = [] - def capture_clone(url, target): + def capture_clone(host, url, target): clone_urls.append(url) Path(target).mkdir(parents=True) @@ -573,7 +573,7 @@ def test_ssh_url_converted_to_https_for_clone( ): clone_urls = [] - def capture_clone(url, target): + def capture_clone(host, url, target): clone_urls.append(url) Path(target).mkdir(parents=True) diff --git a/scripts/gogs b/scripts/gogs new file mode 100755 index 000000000..f83d08cf9 --- /dev/null +++ b/scripts/gogs @@ -0,0 +1,575 @@ +#!/usr/bin/env python3 +"""gh-compatible CLI wrapper for self-hosted Gogs instances. + +Reads KOAN_GOGS_HOST and KOAN_GOGS_TOKEN from the environment. Provides +a subset of the ``gh`` command interface so that tooling written for GitHub +can work with Gogs by swapping the binary name. + +Supported commands: + gogs pr create --title TITLE --body BODY [--draft] [--base BASE] + [--repo OWNER/REPO] [--head HEAD] + gogs pr view NUMBER --repo OWNER/REPO [--json FIELDS] + gogs pr diff NUMBER --repo OWNER/REPO + gogs pr list --repo OWNER/REPO --state STATE + [--json FIELDS] [--jq FILTER] [--author AUTHOR] + [--limit N] + gogs issue create --title TITLE --body BODY [--repo OWNER/REPO] + gogs issue list --state STATE [--repo OWNER/REPO] + [--json FIELDS] [--limit N] + gogs issue edit NUMBER --body BODY [--repo OWNER/REPO] + gogs repo view [--json FIELDS] [--jq FILTER] + gogs repo permissions [--repo OWNER/REPO] [--json FIELDS] [--jq FILTER] + gogs repo fork [--repo OWNER/REPO] [--org ORGANIZATION] + gogs api ENDPOINT [-X METHOD] [--jq FILTER] + [--input -] [-F body=@-] [--paginate] + +Environment: + KOAN_GOGS_HOST — Gogs base URL, e.g. https://git.example.com + KOAN_GOGS_TOKEN — Personal access token +""" + +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Dict, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +GOGS_HOST = os.environ.get("KOAN_GOGS_HOST", "").rstrip("/") +GOGS_TOKEN = os.environ.get("KOAN_GOGS_TOKEN", "") + + +def _require_host() -> None: + if not GOGS_HOST: + _die("KOAN_GOGS_HOST is not set. Point it at your Gogs instance URL.") + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def _headers() -> Dict[str, str]: + h = {"Content-Type": "application/json"} + if GOGS_TOKEN: + h["Authorization"] = f"token {GOGS_TOKEN}" + return h + + +def _request( + method: str, + path: str, + data: Optional[Dict] = None, + params: Optional[Dict] = None, + timeout: int = 30, + full_url: str = "", +) -> Any: + """Make an authenticated Gogs API v1 request, return parsed JSON.""" + _require_host() + if full_url: + url = full_url + else: + url = f"{GOGS_HOST}/api/v1/{path.lstrip('/')}" + if params: + url = url + "?" + urllib.parse.urlencode(params) + body = json.dumps(data).encode() if data is not None else None + req = urllib.request.Request(url, data=body, headers=_headers(), method=method.upper()) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", errors="replace") + return json.loads(raw) if raw.strip() else {} + except urllib.error.HTTPError as exc: + _die(f"Gogs API {method} {path}: HTTP {exc.code}: {exc.read().decode('utf-8', errors='replace')[:300]}") + except Exception as exc: + _die(f"Gogs API {method} {path}: {exc}") + + +def _raw_get(url: str, timeout: int = 30) -> str: + """Fetch a raw (non-JSON) URL with token auth.""" + _require_host() + req = urllib.request.Request(url, headers=_headers()) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + _die(f"Gogs fetch {url}: HTTP {exc.code}") + except Exception as exc: + _die(f"Gogs fetch {url}: {exc}") + + +# --------------------------------------------------------------------------- +# Minimal jq-like filter +# --------------------------------------------------------------------------- + +def _apply_jq(data: Any, filter_str: str) -> str: + """Apply a minimal subset of jq filter expressions to data. + + Supported patterns: + .[].FIELD → newline-separated field values from array + .FIELD.SUBFIELD → nested field access + length → array/object length + .A.B + "/" + .C.D → string concatenation (any number of segments) + """ + f = filter_str.strip() + + # length + if f in ("length", ".length"): + if isinstance(data, (list, dict)): + return str(len(data)) + return "0" + + # .[].FIELD or .[].FIELD.SUBFIELD + array_field = re.match(r"^\.\[\]\.([\w.]+)$", f) + if array_field: + field_path = array_field.group(1).split(".") + results = [] + if isinstance(data, list): + for item in data: + val = _get_path(item, field_path) + if val is not None: + results.append(str(val)) + return "\n".join(results) + + # String concatenation: .A + "/" + .B etc. + if "+" in f: + segments = [s.strip() for s in f.split("+")] + parts = [] + for seg in segments: + if seg.startswith("."): + field_path = seg[1:].split(".") + val = _get_path(data, field_path) or "" + parts.append(str(val)) + else: + # Literal string (possibly quoted) + parts.append(seg.strip("\"'")) + result = "".join(parts) + return result + + # Simple field path: .FIELD or .FIELD.SUBFIELD + if f.startswith("."): + field_path = f[1:].split(".") + val = _get_path(data, field_path) + if val is None: + return "" + if isinstance(val, (dict, list)): + return json.dumps(val) + return str(val) + + # Object construction: {"key": .field} — too complex, return raw JSON + return json.dumps(data) + + +def _get_path(obj: Any, keys: List[str]) -> Any: + """Traverse a nested dict/list by dotted key path.""" + for key in keys: + if not key: + continue + if isinstance(obj, dict): + obj = obj.get(key) + else: + return None + return obj + + +# --------------------------------------------------------------------------- +# Field normalisation (Gogs → gh compatible) +# --------------------------------------------------------------------------- + +_PR_FIELD_MAP = { + "number": lambda pr: pr.get("number"), + "title": lambda pr: pr.get("title", ""), + "body": lambda pr: pr.get("body", ""), + "state": lambda pr: pr.get("state", ""), + "headRefName": lambda pr: (pr.get("head") or {}).get("ref", ""), + "baseRefName": lambda pr: (pr.get("base") or {}).get("ref", ""), + "url": lambda pr: pr.get("html_url", ""), + "merged": lambda pr: pr.get("merged", False), +} + +_ISSUE_FIELD_MAP = { + "number": lambda i: i.get("number"), + "title": lambda i: i.get("title", ""), + "body": lambda i: i.get("body", ""), + "url": lambda i: i.get("html_url", ""), + "state": lambda i: i.get("state", ""), +} + + +def _pick_fields(obj: Dict, field_map: Dict, fields: List[str]) -> Dict: + """Build a dict with only the requested fields, normalised via field_map.""" + result = {} + for field in fields: + if field in field_map: + result[field] = field_map[field](obj) + elif field in obj: + result[field] = obj[field] + return result + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +def _out(text: str) -> None: + print(text, flush=True) + + +def _die(msg: str) -> None: + print(f"gogs: {msg}", file=sys.stderr) + sys.exit(1) + + +def _output_json_or_jq(data: Any, json_fields: str, jq_filter: str) -> None: + """Output data, applying field selection and/or jq filter.""" + if json_fields and isinstance(data, list): + fields = [f.strip() for f in json_fields.split(",")] + # Detect which field map to use based on keys present + if data: + sample = data[0] if isinstance(data[0], dict) else {} + if "head" in sample or "headRefName" in sample: + field_map = _PR_FIELD_MAP + else: + field_map = _ISSUE_FIELD_MAP + data = [_pick_fields(item, field_map, fields) for item in data] + elif json_fields and isinstance(data, dict): + fields = [f.strip() for f in json_fields.split(",")] + if "head" in data or "headRefName" in data: + field_map = _PR_FIELD_MAP + else: + field_map = _ISSUE_FIELD_MAP + data = _pick_fields(data, field_map, fields) + + if jq_filter: + _out(_apply_jq(data, jq_filter)) + else: + _out(json.dumps(data, ensure_ascii=False)) + + +# --------------------------------------------------------------------------- +# Argument parser (manual, no argparse dependency to keep script portable) +# --------------------------------------------------------------------------- + +def _pop(args: List[str], flag: str) -> Optional[str]: + """Pop a --flag VALUE pair from args list, return VALUE or None.""" + try: + idx = args.index(flag) + val = args[idx + 1] + args.pop(idx) + args.pop(idx) + return val + except (ValueError, IndexError): + return None + + +def _has(args: List[str], flag: str) -> bool: + """Return True and remove flag if present.""" + if flag in args: + args.remove(flag) + return True + return False + + +def _positional(args: List[str]) -> str: + """Return the first non-flag positional argument.""" + for i, a in enumerate(args): + if not a.startswith("-"): + return args.pop(i) + return "" + + +# --------------------------------------------------------------------------- +# Command implementations +# --------------------------------------------------------------------------- + +def cmd_pr_create(args: List[str]) -> None: + title = _pop(args, "--title") or _die("--title required") + body = _pop(args, "--body") or "" + base = _pop(args, "--base") + repo = _pop(args, "--repo") + head = _pop(args, "--head") + _has(args, "--draft") # Accepted but ignored — Gogs has no draft PRs + + if not repo: + repo = _repo_from_git_remote() + owner, repo_name = _split_repo(repo) + + payload = {"title": title, "body": body} + if base: + payload["base"] = base + if head: + payload["head"] = head + + data = _request("POST", f"repos/{owner}/{repo_name}/pulls", payload) + html_url = data.get("html_url") or f"{GOGS_HOST}/{owner}/{repo_name}/pulls/{data.get('number')}" + _out(html_url) + + +def cmd_pr_view(args: List[str]) -> None: + number = _positional(args) or _die("pr view requires a PR number") + repo = _pop(args, "--repo") or _die("--repo required") + json_fields = _pop(args, "--json") or "" + jq_filter = _pop(args, "--jq") or "" + + owner, repo_name = _split_repo(repo) + data = _request("GET", f"repos/{owner}/{repo_name}/pulls/{number}") + _output_json_or_jq(data, json_fields, jq_filter) + + +def cmd_pr_diff(args: List[str]) -> None: + number = _positional(args) or _die("pr diff requires a PR number") + repo = _pop(args, "--repo") or _die("--repo required") + owner, repo_name = _split_repo(repo) + diff = _raw_get(f"{GOGS_HOST}/{owner}/{repo_name}/pulls/{number}.diff") + _out(diff) + + +def cmd_pr_list(args: List[str]) -> None: + repo = _pop(args, "--repo") or _repo_from_git_remote() + state = _pop(args, "--state") or "open" + json_fields = _pop(args, "--json") or "" + jq_filter = _pop(args, "--jq") or "" + author = _pop(args, "--author") or "" + limit = int(_pop(args, "--limit") or "50") + + owner, repo_name = _split_repo(repo) + + params: Dict = {"limit": str(limit)} + # Gogs uses state=open/closed for PR listing + if state in ("merged", "closed"): + params["state"] = "closed" + else: + params["state"] = state + + items = _request("GET", f"repos/{owner}/{repo_name}/pulls", params=params) + if not isinstance(items, list): + items = [] + + if author: + items = [ + pr for pr in items + if isinstance(pr, dict) + and (pr.get("user") or {}).get("login", "").lower() == author.lower() + ] + + if state == "merged": + items = [pr for pr in items if isinstance(pr, dict) and pr.get("merged")] + + _output_json_or_jq(items, json_fields, jq_filter) + + +def cmd_issue_create(args: List[str]) -> None: + title = _pop(args, "--title") or _die("--title required") + body = _pop(args, "--body") or "" + repo = _pop(args, "--repo") or _repo_from_git_remote() + + owner, repo_name = _split_repo(repo) + data = _request("POST", f"repos/{owner}/{repo_name}/issues", {"title": title, "body": body}) + html_url = data.get("html_url") or f"{GOGS_HOST}/{owner}/{repo_name}/issues/{data.get('number')}" + _out(html_url) + + +def cmd_issue_list(args: List[str]) -> None: + repo = _pop(args, "--repo") or _repo_from_git_remote() + state = _pop(args, "--state") or "open" + json_fields = _pop(args, "--json") or "" + jq_filter = _pop(args, "--jq") or "" + limit = int(_pop(args, "--limit") or "50") + + owner, repo_name = _split_repo(repo) + params = {"state": state, "type": "issues", "limit": str(limit)} + items = _request("GET", f"repos/{owner}/{repo_name}/issues", params=params) + if not isinstance(items, list): + items = [] + + _output_json_or_jq(items, json_fields, jq_filter) + + +def cmd_issue_edit(args: List[str]) -> None: + number = _positional(args) or _die("issue edit requires an issue number") + body = _pop(args, "--body") or _die("--body required") + repo = _pop(args, "--repo") or _repo_from_git_remote() + + owner, repo_name = _split_repo(repo) + _request("PATCH", f"repos/{owner}/{repo_name}/issues/{number}", {"body": body}) + + +def cmd_repo_view(args: List[str]) -> None: + json_fields = _pop(args, "--json") or "" + jq_filter = _pop(args, "--jq") or "" + + # Infer repo from git remote + repo = _repo_from_git_remote() + owner, repo_name = _split_repo(repo) + data = _request("GET", f"repos/{owner}/{repo_name}") + + # Normalise parent field to match gh's output shape + parent = data.get("parent") + if parent and isinstance(parent, dict): + normalised = { + "parent": { + "owner": {"login": (parent.get("owner") or {}).get("login", "")}, + "name": parent.get("name", ""), + } + } + else: + normalised = {"parent": None} + + _output_json_or_jq(normalised, json_fields, jq_filter) + + +def cmd_repo_permissions(args: List[str]) -> None: + repo = _pop(args, "--repo") + json_fields = _pop(args, "--json") or "" + jq_filter = _pop(args, "--jq") or "" + + if not repo: + repo = _repo_from_git_remote() + owner, repo_name = _split_repo(repo) + data = _request("GET", f"repos/{owner}/{repo_name}") + + perms = data.get("permissions") or {} + # Normalise to a consistent shape regardless of Gogs version + normalised = { + "admin": bool(perms.get("admin", False)), + "push": bool(perms.get("push", False)), + "pull": bool(perms.get("pull", False)), + } + + if jq_filter: + _out(_apply_jq(normalised, jq_filter)) + elif json_fields: + fields = [f.strip() for f in json_fields.split(",")] + _out(json.dumps({k: v for k, v in normalised.items() if k in fields}, ensure_ascii=False)) + else: + _out(json.dumps(normalised, ensure_ascii=False)) + + +def cmd_repo_fork(args: List[str]) -> None: + repo = _pop(args, "--repo") + org = _pop(args, "--org") or "" + + if not repo: + repo = _repo_from_git_remote() + owner, repo_name = _split_repo(repo) + + payload: Dict = {} + if org: + payload["organization"] = org + + data = _request("POST", f"repos/{owner}/{repo_name}/forks", payload or None) + html_url = data.get("html_url") or f"{GOGS_HOST}/{data.get('full_name', '')}" + _out(html_url) + + +def cmd_api(args: List[str]) -> None: + endpoint = _positional(args) or _die("api requires an endpoint") + method = _pop(args, "-X") or "GET" + jq_filter = _pop(args, "--jq") or "" + _has(args, "--paginate") # Pagination not implemented; accepted silently + + # Body via stdin: --input - or -F body=@- + body_data = None + if _has(args, "--input"): + # Next arg is '-' (stdin) + _positional(args) # discard the '-' + body_data = sys.stdin.read() + elif "-F" in args: + _pop(args, "-F") # consume -F body=@- + body_data = sys.stdin.read() + + _require_host() + url = f"{GOGS_HOST}/api/v1/{endpoint.lstrip('/')}" + headers = _headers() + + body = body_data.encode() if body_data else None + req = urllib.request.Request(url, data=body, headers=headers, method=method.upper()) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode("utf-8", errors="replace") + data = json.loads(raw) if raw.strip() else {} + except urllib.error.HTTPError as exc: + _die(f"API {method} {endpoint}: HTTP {exc.code}: {exc.read().decode('utf-8', errors='replace')[:300]}") + except Exception as exc: + _die(f"API {method} {endpoint}: {exc}") + + if jq_filter: + _out(_apply_jq(data, jq_filter)) + else: + _out(json.dumps(data, ensure_ascii=False)) + + +# --------------------------------------------------------------------------- +# Git remote helper +# --------------------------------------------------------------------------- + +def _repo_from_git_remote() -> str: + """Infer owner/repo from the current directory's git remote.""" + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL, + ) + if result.returncode == 0: + url = result.stdout.strip() + match = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?$", url) + if match: + return f"{match.group(1)}/{match.group(2)}" + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + pass + _die("Could not infer repo from git remote. Pass --repo OWNER/REPO explicitly.") + + +def _split_repo(repo: Optional[str]) -> Tuple[str, str]: + if not repo: + _die("--repo OWNER/REPO is required") + parts = str(repo).split("/", 1) + if len(parts) != 2 or not all(parts): + _die(f"Invalid --repo format: {repo!r} (expected OWNER/REPO)") + return parts[0], parts[1] + + +# --------------------------------------------------------------------------- +# Main dispatch +# --------------------------------------------------------------------------- + +def main() -> None: + args = sys.argv[1:] + if not args: + print(__doc__, file=sys.stderr) + sys.exit(1) + + cmd = args.pop(0) + sub = args.pop(0) if args and not args[0].startswith("-") else "" + + dispatch = { + ("pr", "create"): cmd_pr_create, + ("pr", "view"): cmd_pr_view, + ("pr", "diff"): cmd_pr_diff, + ("pr", "list"): cmd_pr_list, + ("issue", "create"): cmd_issue_create, + ("issue", "list"): cmd_issue_list, + ("issue", "edit"): cmd_issue_edit, + ("repo", "view"): cmd_repo_view, + ("repo", "permissions"): cmd_repo_permissions, + ("repo", "fork"): cmd_repo_fork, + ("api", ""): cmd_api, + } + + handler = dispatch.get((cmd, sub)) + if handler is None: + _die(f"Unsupported command: {cmd} {sub}".strip()) + + # For 'api' the endpoint is the remaining first positional arg + if cmd == "api": + args.insert(0, sub) # sub was the endpoint, put it back + handler(args) + + +if __name__ == "__main__": + main()