From 877eebc0562939ce2018ddd3f4f30282f5c30250 Mon Sep 17 00:00:00 2001 From: Andy Baugh Date: Thu, 4 Jun 2026 10:21:43 -0500 Subject: [PATCH 01/27] fix(awake): don't sys.exit(1) on non-telegram messaging providers check_config() hardcoded a KOAN_TELEGRAM_TOKEN/CHAT_ID presence check that exited non-telegram setups (Slack, Matrix) before the messaging- provider abstraction ran. Gate the check to _resolve_provider_name() == "telegram" so each provider's own configure() does the real credential check. Also: - Guard the BOT_TOKEN / CHAT_ID startup log lines to skip on empty values (avoids slicing an empty BOT_TOKEN and logging an empty chat). - Main-loop chat-id filter now matches against both the active provider's get_channel_id() and CHAT_ID (telegram-only), letting slack/matrix events through while preserving backward-compat with tests that patch CHAT_ID directly. Extracted from WIP commit 14598ab7. Co-Authored-By: Claude Opus 4.8 (1M context) --- koan/app/awake.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/koan/app/awake.py b/koan/app/awake.py index c38f1c74c..28dcfb5e0 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(): @@ -743,8 +748,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 +818,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]}") From 1ca695238a1daffa155a0f165dd4702687895b8e Mon Sep 17 00:00:00 2001 From: Andy Baugh Date: Thu, 4 Jun 2026 10:30:21 -0500 Subject: [PATCH 02/27] fix(matrix): mint Telegram-shaped wrapper in HTTP fallback + dynamic bridge banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Matrix HTTP fallback returned raw Matrix events as Update.raw_data, but awake.py's main loop expects Telegram-Bot-API-shaped dicts (update_id, message.chat.id, …). Sending any message to a room crashed the bridge with KeyError: 'update_id'. Mint the wrapper in _parse_room_events. Also unhardcode the bridge banner provider name; it was stuck on "telegram" regardless of which provider was actually active. Extracted from WIP commit 247fc586. Co-Authored-By: Claude Opus 4.8 (1M context) --- koan/app/awake.py | 3 ++- koan/app/messaging/matrix.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/koan/app/awake.py b/koan/app/awake.py index 28dcfb5e0..0119ebb2e 100755 --- a/koan/app/awake.py +++ b/koan/app/awake.py @@ -732,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 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 From bc0458495486aced36b03b7eaafe0cc36b25cb7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Wed, 3 Jun 2026 00:47:57 +0000 Subject: [PATCH 03/27] feat(forge): add Gogs forge provider for self-hosted instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GogsForge — a ForgeProvider implementation for self-hosted Gogs (gogs.io) instances, plus a gh-compatible scripts/gogs CLI wrapper for human use. Configuration is via KOAN_GOGS_HOST and KOAN_GOGS_TOKEN. - koan/app/gogs_auth.py: env-var helpers (host, token, headers) - koan/app/gogs_url_parser.py: URL parsing with runtime host pattern - koan/app/forge/gogs.py: GogsForge (PR create/view/list, issue create, API passthrough, fork detection, URL construction) - scripts/gogs: standalone gh-compatible CLI wrapper using Gogs REST API v1 - forge/registry.py + forge/__init__.py: register gogs, auto-detect by host - env.example: document KOAN_GOGS_HOST and KOAN_GOGS_TOKEN - 58 new tests covering auth, URL parsing, forge ops, registry, factory Also fixes a pre-existing bug in forge/__init__.py where _resolve_forge_config called the non-existent app.utils.get_koan_root — replaced with os.environ. Co-Authored-By: Claude Sonnet 4.6 --- env.example | 12 + koan/app/forge/__init__.py | 38 ++- koan/app/forge/gogs.py | 439 ++++++++++++++++++++++++++++ koan/app/forge/registry.py | 11 +- koan/app/gogs_auth.py | 39 +++ koan/app/gogs_url_parser.py | 134 +++++++++ koan/tests/test_forge_gogs.py | 493 +++++++++++++++++++++++++++++++ scripts/gogs | 527 ++++++++++++++++++++++++++++++++++ 8 files changed, 1680 insertions(+), 13 deletions(-) create mode 100644 koan/app/forge/gogs.py create mode 100644 koan/app/gogs_auth.py create mode 100644 koan/app/gogs_url_parser.py create mode 100644 koan/tests/test_forge_gogs.py create mode 100755 scripts/gogs diff --git a/env.example b/env.example index bbd9206a8..f76e4de79 100644 --- a/env.example +++ b/env.example @@ -81,6 +81,18 @@ # The user must be pre-authenticated with: gh auth login --user # GITHUB_USER=your-bot-name +# ========================================================================= +# 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 + # GitHub webhook secret (optional — enables push-based notification triggering) # When github.webhook.enabled is true in config.yaml, the bridge starts a local # HTTP receiver. Set this to a strong random secret and configure the SAME secret diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index 488667798..2b5b91f9b 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 """ @@ -74,11 +74,17 @@ def detect_forge_from_url(url: str) -> ForgeProvider: if "github.com" in lower or "github.enterprise" in lower: 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 gogs_host and gogs_host in lower: + 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 +106,12 @@ 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: + return DEFAULT_FORGE, None config = load_projects_config(koan_root) if not config: return DEFAULT_FORGE, None @@ -124,3 +132,15 @@ 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: + return "" diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py new file mode 100644 index 000000000..76e2b5773 --- /dev/null +++ b/koan/app/forge/gogs.py @@ -0,0 +1,439 @@ +"""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 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 + + +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. + """ + 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") + 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") + ] + + # ------------------------------------------------------------------ + # Issue operations + # ------------------------------------------------------------------ + + def issue_create( + self, + title: str, + body: str, + labels: Optional[List[str]] = None, + cwd: Optional[str] = None, + ) -> str: + # repo must be derivable from cwd; Gogs API requires explicit owner/repo. + # Callers that go through GogsForge always pass a repo param via + # the overloaded run_api() → issue creation should specify repo. + # For the common direct-create path, attempt to infer from git remote. + raise NotImplementedError( + "issue_create via GogsForge requires a repo argument; " + "use issue_create_in_repo() instead or pass repo via run_api()." + ) + + 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. + """ + 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: + pass + return None + + # ------------------------------------------------------------------ + # 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)." + ) + + 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 _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/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/tests/test_forge_gogs.py b/koan/tests/test_forge_gogs.py new file mode 100644 index 000000000..7ff31d7b2 --- /dev/null +++ b/koan/tests/test_forge_gogs.py @@ -0,0 +1,493 @@ +"""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 == [] + + +# --------------------------------------------------------------------------- +# 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_not_implemented(self, forge): + with pytest.raises(NotImplementedError): + 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) diff --git a/scripts/gogs b/scripts/gogs new file mode 100755 index 000000000..c95b0b05e --- /dev/null +++ b/scripts/gogs @@ -0,0 +1,527 @@ +#!/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 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_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, + ("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() From b87b1f1a31d6857dabd2d61df4b82b73674df1fe Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 16:26:00 +0000 Subject: [PATCH 04/27] Reformat/clarify env.example a bit Some things needed to be moved around for clarity as to what they were relevant to. --- env.example | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/env.example b/env.example index f76e4de79..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,11 +83,23 @@ # 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 # GITHUB_USER=your-bot-name +# GitHub webhook secret (optional — enables push-based notification triggering) +# When github.webhook.enabled is true in config.yaml, the bridge starts a local +# HTTP receiver. Set this to a strong random secret and configure the SAME secret +# in the GitHub repo webhook settings (Settings → Webhooks → Secret). +# Generate one with: openssl rand -hex 32 +# See docs/messaging/github-webhooks.md for the full setup (tunnel + webhook config). +# KOAN_GITHUB_WEBHOOK_SECRET= + # ========================================================================= # GOGS CONFIGURATION (optional — for self-hosted Gogs instances) # ========================================================================= @@ -93,14 +112,3 @@ # Personal access token — generate one at: https:///user/settings/applications # KOAN_GOGS_TOKEN=your-token-here -# GitHub webhook secret (optional — enables push-based notification triggering) -# When github.webhook.enabled is true in config.yaml, the bridge starts a local -# HTTP receiver. Set this to a strong random secret and configure the SAME secret -# in the GitHub repo webhook settings (Settings → Webhooks → Secret). -# Generate one with: openssl rand -hex 32 -# 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) - -# Note: max_runs_per_day and interval_seconds are configured in config.yaml From 65a5f832b7d737277712b62aaf6d17db493d73bc Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 16:36:21 +0000 Subject: [PATCH 05/27] Change example in projects.example.yaml to prefer forge_url See _resolve_forge_config in __init.py, it does the same. --- projects.example.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects.example.yaml b/projects.example.yaml index 2b1be4441..de247881b 100644 --- a/projects.example.yaml +++ b/projects.example.yaml @@ -155,10 +155,10 @@ projects: # Example: your main project (minimal config — inherits all defaults) myapp: path: "/Users/yourname/workspace/myapp" - # github_url: "yourname/myapp" # Auto-detected from git remote; override if needed + # forge_url (or github_url): "yourname/myapp" # Auto-detected from git remote; override if needed # issue_tracker: # provider: github # github | jira - # repo: "yourname/myapp" # Optional; defaults to github_url / git remote + # repo: "yourname/myapp" # Optional; defaults to forge_url / git remote # default_branch: "main" # Optional tracker-specific target branch # git_auto_merge: # enabled: true @@ -167,7 +167,7 @@ projects: # Example: a project whose issues live in Jira but whose PRs go to GitHub # jira-backed-app: # path: "/Users/yourname/workspace/jira-backed-app" - # github_url: "yourname/jira-backed-app" + # forge_url: "yourname/jira-backed-app" # issue_tracker: # provider: jira # jira_project: PROJ From bd09bc7c56f616a67590a446d1403a8ed361581c Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 16:40:15 +0000 Subject: [PATCH 06/27] pass base_url to all forge classes, not just GitHubForge There was a TODO comment that this "ought" to happen in the event that we implement phase two, and it does not appear as though this will break anything, so we do it. --- koan/app/forge/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index 2b5b91f9b..355113c2a 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -48,10 +48,7 @@ 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: - return cls(base_url=forge_url) - return cls() + return cls(base_url=forge_url) def detect_forge_from_url(url: str) -> ForgeProvider: From 1e096482f22c3d649955f2e1144e28e7450a5944 Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 17:09:14 +0000 Subject: [PATCH 07/27] Implement issue_create in gogs forge --- koan/app/forge/gogs.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py index 76e2b5773..ae4c64228 100644 --- a/koan/app/forge/gogs.py +++ b/koan/app/forge/gogs.py @@ -188,14 +188,13 @@ def issue_create( labels: Optional[List[str]] = None, cwd: Optional[str] = None, ) -> str: - # repo must be derivable from cwd; Gogs API requires explicit owner/repo. - # Callers that go through GogsForge always pass a repo param via - # the overloaded run_api() → issue creation should specify repo. - # For the common direct-create path, attempt to infer from git remote. - raise NotImplementedError( - "issue_create via GogsForge requires a repo argument; " - "use issue_create_in_repo() instead or pass repo via run_api()." - ) + # 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 + owner, repo_name = _owner_repo_from_git_remote(cwd) + if (not owner) or (not repo_name): + 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") + repo = f"{owner}/{repo_name}" + return self.issue_create_in_repo(repo, title, body, labels) def issue_create_in_repo( self, From 4823df11e039c1b90d60c51d33dd31bfccbbb6af Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 17:35:02 +0000 Subject: [PATCH 08/27] Do stricter check against repo uri when discerning what forge to load This way we can't have github.com.nefarious.org match, for example This is still possible in the case of github.enterprise.nefarious.org, but I presume this is an intentional use case. --- koan/app/forge/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index 355113c2a..3f93a665f 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -68,12 +68,19 @@ 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 == "github.com" or "github.enterprise" in netloc: return GitHubForge() # Phase 2: self-hosted Gogs — detected by KOAN_GOGS_HOST match gogs_host = _gogs_host_for_detection() - if gogs_host and gogs_host in lower: + if netloc == gogs_host: from app.forge.gogs import GogsForge return GogsForge() From 365f8fea5553cfdad71621f7490d3e9344c2da5e Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 17:43:50 +0000 Subject: [PATCH 09/27] Revert "pass base_url to all forge classes, not just GitHubForge" This reverts commit 2ba4dcdcd1f6e1a24474747f2d730d2b30711600. Apparently this *does* break things. Feels like a 'load bearing' bug though. --- koan/app/forge/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index 3f93a665f..c270a9d9f 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -48,7 +48,10 @@ def get_forge(project_name: Optional[str] = None) -> ForgeProvider: # Unknown forge type — fall back to GitHub to avoid breaking callers. cls = GitHubForge - return cls(base_url=forge_url) + # TODO(Phase 2): pass base_url to all forge classes, not just GitHubForge. + if forge_url and cls is GitHubForge: + return cls(base_url=forge_url) + return cls() def detect_forge_from_url(url: str) -> ForgeProvider: From 698e4bbf26dfb7b6c5b1b09c5180d33663d9519b Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 17:45:32 +0000 Subject: [PATCH 10/27] If we have a forge url attempt to pass it to the forge class. --- koan/app/forge/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index c270a9d9f..bbba71af4 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -48,8 +48,8 @@ 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() From 685cbd12f04ec4a6a156b70dc5a995f9b470932c Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 17:50:16 +0000 Subject: [PATCH 11/27] better? guard against incapacity to determine repo information in gogs forge --- koan/app/forge/gogs.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py index ae4c64228..c5497833e 100644 --- a/koan/app/forge/gogs.py +++ b/koan/app/forge/gogs.py @@ -190,9 +190,11 @@ def issue_create( ) -> 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 - owner, repo_name = _owner_repo_from_git_remote(cwd) - if (not owner) or (not repo_name): + 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) From 5d4a17d624c7020ccc1e238ca93ae3f602567e3c Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 18:28:07 +0000 Subject: [PATCH 12/27] Blow up in _api and _raw_get when token is not set in gogs forge --- koan/app/forge/gogs.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py index c5497833e..58cbfc461 100644 --- a/koan/app/forge/gogs.py +++ b/koan/app/forge/gogs.py @@ -303,6 +303,14 @@ def _require_host(self) -> None: "(e.g. https://git.example.com)." ) + 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, @@ -327,6 +335,7 @@ def _api( RuntimeError: On HTTP error or if KOAN_GOGS_HOST is not set. """ self._require_host() + self._require_token() from app.gogs_auth import get_gogs_token @@ -360,6 +369,7 @@ def _api( def _raw_get(self, url: str, timeout: int = 30) -> str: """Fetch a raw URL (non-JSON) with token auth.""" self._require_host() + self._require_token() from app.gogs_auth import get_gogs_token token = get_gogs_token() From 5a2495ed7e0d93d1f1fc295aa9722f899fe81666 Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 18:33:17 +0000 Subject: [PATCH 13/27] warn on failure to get gogs host in koan/app/forge/__init__.py This follows the pattern established earlier therein. --- koan/app/forge/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index bbba71af4..f9ccf8ec1 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -150,4 +150,5 @@ def _gogs_host_for_detection() -> str: 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 "" From 85fcd2d91a31a3810f356754577301a95bb49bce Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 18:35:16 +0000 Subject: [PATCH 14/27] warn in the event KOAN_ROOT can't be determined by forges --- koan/app/forge/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index f9ccf8ec1..3d6d6633f 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -118,6 +118,7 @@ def _resolve_forge_config(project_name: Optional[str]) -> tuple: 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: From c4a59c6a6aa9568766c81c4137e202ccbda4ae1a Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 18:39:01 +0000 Subject: [PATCH 15/27] warn in the event fork detection on gogs fails --- koan/app/forge/gogs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py index 58cbfc461..3bec583ff 100644 --- a/koan/app/forge/gogs.py +++ b/koan/app/forge/gogs.py @@ -281,6 +281,7 @@ def detect_fork(self, project_path: str) -> Optional[str]: if p_owner and p_name: return f"{p_owner}/{p_name}" except RuntimeError: + log.warning("GOGS fork detection failed for %s: %s", project_path, exc); pass return None From 666d5e44d52272b2d4fe443de6c20ec35c973bbc Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 18:43:11 +0000 Subject: [PATCH 16/27] throw in the event we can't discern the URL of a PR created in GOGS --- koan/app/forge/gogs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py index 3bec583ff..34a932ca3 100644 --- a/koan/app/forge/gogs.py +++ b/koan/app/forge/gogs.py @@ -130,6 +130,8 @@ def pr_create( 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 From e8aa4fde53205d9fb33caad6d9b8a33bb3f358d0 Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 18:44:19 +0000 Subject: [PATCH 17/27] forgot netloc was an attr, not a method --- koan/app/forge/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index 3d6d6633f..a77a5bfbe 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -74,7 +74,7 @@ def detect_forge_from_url(url: str) -> ForgeProvider: from urllib.parse import urlparse parsed=urlparse(lower); - netloc = parsed.netloc(); + netloc = parsed.netloc; # While this still allows for nefarious github.enterprise.whatever, # we presume that is intentional subdomain design in that case From 7944a5ae4c4590817e23a25c8b3ab01984ebd240 Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 18:45:49 +0000 Subject: [PATCH 18/27] remove some semicolon perlisms --- koan/app/forge/__init__.py | 4 ++-- koan/app/forge/gogs.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index a77a5bfbe..e9ebbb4b2 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -72,9 +72,9 @@ def detect_forge_from_url(url: str) -> ForgeProvider: lower = url.lower() from urllib.parse import urlparse - parsed=urlparse(lower); + parsed=urlparse(lower) - netloc = parsed.netloc; + netloc = parsed.netloc # While this still allows for nefarious github.enterprise.whatever, # we presume that is intentional subdomain design in that case diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py index 34a932ca3..b180f2168 100644 --- a/koan/app/forge/gogs.py +++ b/koan/app/forge/gogs.py @@ -283,7 +283,7 @@ def detect_fork(self, project_path: str) -> Optional[str]: if p_owner and p_name: return f"{p_owner}/{p_name}" except RuntimeError: - log.warning("GOGS fork detection failed for %s: %s", project_path, exc); + log.warning("GOGS fork detection failed for %s: %s", project_path, exc) pass return None From c91f0bebb92935e8ea639bd68fbcbff709bec3b6 Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 18:49:18 +0000 Subject: [PATCH 19/27] Don't break api.github.com, etc when determining repo urls are good In Forges we want to prevent github.com.nefarious, but not good.github.com --- koan/app/forge/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index e9ebbb4b2..970be63dc 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -78,7 +78,7 @@ def detect_forge_from_url(url: str) -> ForgeProvider: # While this still allows for nefarious github.enterprise.whatever, # we presume that is intentional subdomain design in that case - if netloc == "github.com" or "github.enterprise" in netloc: + if netloc.endswith("github.com") or "github.enterprise" in netloc: return GitHubForge() # Phase 2: self-hosted Gogs — detected by KOAN_GOGS_HOST match From a2f6aa2e16262157d78e6b64a13964854eb4f84c Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 19:01:28 +0000 Subject: [PATCH 20/27] live a bit more dangerously RE requiring a token with GOGS repos. This way we can see public repos at least. --- koan/app/forge/gogs.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py index b180f2168..47b84c190 100644 --- a/koan/app/forge/gogs.py +++ b/koan/app/forge/gogs.py @@ -119,6 +119,7 @@ def pr_create( 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: @@ -192,6 +193,7 @@ def issue_create( ) -> 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") @@ -218,6 +220,7 @@ def issue_create_in_repo( 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. @@ -306,6 +309,9 @@ def _require_host(self) -> None: "(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(): @@ -338,7 +344,6 @@ def _api( RuntimeError: On HTTP error or if KOAN_GOGS_HOST is not set. """ self._require_host() - self._require_token() from app.gogs_auth import get_gogs_token @@ -372,7 +377,6 @@ def _api( def _raw_get(self, url: str, timeout: int = 30) -> str: """Fetch a raw URL (non-JSON) with token auth.""" self._require_host() - self._require_token() from app.gogs_auth import get_gogs_token token = get_gogs_token() From 85bf495edc1558b5dae928e3e7eb119179dde69b Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 19:12:16 +0000 Subject: [PATCH 21/27] Revert "Change example in projects.example.yaml to prefer forge_url" This reverts commit 363477279d963c304e68b36385bdd7029174210f. --- projects.example.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects.example.yaml b/projects.example.yaml index de247881b..2b1be4441 100644 --- a/projects.example.yaml +++ b/projects.example.yaml @@ -155,10 +155,10 @@ projects: # Example: your main project (minimal config — inherits all defaults) myapp: path: "/Users/yourname/workspace/myapp" - # forge_url (or github_url): "yourname/myapp" # Auto-detected from git remote; override if needed + # github_url: "yourname/myapp" # Auto-detected from git remote; override if needed # issue_tracker: # provider: github # github | jira - # repo: "yourname/myapp" # Optional; defaults to forge_url / git remote + # repo: "yourname/myapp" # Optional; defaults to github_url / git remote # default_branch: "main" # Optional tracker-specific target branch # git_auto_merge: # enabled: true @@ -167,7 +167,7 @@ projects: # Example: a project whose issues live in Jira but whose PRs go to GitHub # jira-backed-app: # path: "/Users/yourname/workspace/jira-backed-app" - # forge_url: "yourname/jira-backed-app" + # github_url: "yourname/jira-backed-app" # issue_tracker: # provider: jira # jira_project: PROJ From 4cc9b37cee881df42dad5a750bcc128f397bbc9a Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 19:20:31 +0000 Subject: [PATCH 22/27] Add a note that forge_url is not to be trusted in any way Nothing else in the project refers to it ever, so you shouldn't use it At least not until every other place we use github_url in the project gets similar treatment. --- koan/app/forge/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/koan/app/forge/__init__.py b/koan/app/forge/__init__.py index 970be63dc..ff64878a1 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -127,6 +127,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 From 812a5dbe28f55da95dd3c6258cc660051d2e4f0b Mon Sep 17 00:00:00 2001 From: George Baugh Date: Wed, 3 Jun 2026 19:23:56 +0000 Subject: [PATCH 23/27] import logger module in gogs forge module --- koan/app/forge/gogs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/koan/app/forge/gogs.py b/koan/app/forge/gogs.py index 47b84c190..dd6af9bec 100644 --- a/koan/app/forge/gogs.py +++ b/koan/app/forge/gogs.py @@ -15,6 +15,7 @@ FEATURE_PR_REVIEW_COMMENTS — Gogs PR review API is limited """ +import logging import json import urllib.error import urllib.parse @@ -23,6 +24,7 @@ from app.forge.base import FEATURE_ISSUES, FEATURE_PR, ForgeProvider +log = logging.getLogger(__name__) class GogsForge(ForgeProvider): """Forge implementation for self-hosted Gogs instances. From 5afa1c478843341d9456d04c097ef863108b19fc Mon Sep 17 00:00:00 2001 From: "George S. Baugh" Date: Wed, 3 Jun 2026 20:50:16 +0000 Subject: [PATCH 24/27] Understand gogs uris in handler.py We're able to clone, but not yet fork or discern if we have push perms I've left TODOS in those places --- koan/skills/core/add_project/handler.py | 161 +++++++++++++++++------- 1 file changed, 117 insertions(+), 44 deletions(-) diff --git a/koan/skills/core/add_project/handler.py b/koan/skills/core/add_project/handler.py index ed9a5a8c3..1945b9fde 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 f"Could not determine hostname of your git server" + if not project_name: project_name = repo @@ -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,51 @@ 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: + return 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 +218,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 +227,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 +281,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 +302,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 +312,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.""" From e73ccc1a343722f35122430a85a4065885d3d0cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Wed, 3 Jun 2026 21:12:24 +0000 Subject: [PATCH 25/27] feat(forge/gogs): add repo permissions and fork commands to scripts/gogs Extends the gogs CLI wrapper with two new subcommands: - `gogs repo permissions [--repo OWNER/REPO] [--json FIELDS] [--jq FILTER]` Fetches the token's permission level on a repo (admin/push/pull booleans), mirroring the `permissions` object from the Gogs API. Defaults to false for any field absent from the API response. - `gogs repo fork [--repo OWNER/REPO] [--org ORGANIZATION]` Creates a fork of the given repo via POST /repos/{owner}/{repo}/forks. Optionally forks into an organisation. Returns the fork's HTML URL. Also: - Fixes pre-existing test bug: test_issue_create_raises_not_implemented now correctly expects RuntimeError (the implementation changed on this branch) - Fixes pre-existing ruff F541 lint error in add_project/handler.py - Adds 7 new tests covering the two new commands (permissions JSON output, defaults, jq filter, field projection; fork URL, org payload, fallback URL) Co-Authored-By: Claude Sonnet 4.6 --- koan/skills/core/add_project/handler.py | 2 +- koan/tests/test_forge_gogs.py | 131 +++++++++++++++++++++++- scripts/gogs | 50 ++++++++- 3 files changed, 179 insertions(+), 4 deletions(-) diff --git a/koan/skills/core/add_project/handler.py b/koan/skills/core/add_project/handler.py index 1945b9fde..8045f589c 100644 --- a/koan/skills/core/add_project/handler.py +++ b/koan/skills/core/add_project/handler.py @@ -41,7 +41,7 @@ def handle(ctx): parsed = parse.urlparse(url) host = parsed.netloc if not host: - return f"Could not determine hostname of your git server" + return "Could not determine hostname of your git server" if not project_name: project_name = repo diff --git a/koan/tests/test_forge_gogs.py b/koan/tests/test_forge_gogs.py index 7ff31d7b2..f0ade7c97 100644 --- a/koan/tests/test_forge_gogs.py +++ b/koan/tests/test_forge_gogs.py @@ -312,8 +312,8 @@ def test_issue_create_in_repo_returns_url(self, forge): 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_not_implemented(self, forge): - with pytest.raises(NotImplementedError): + def test_issue_create_raises_on_missing_cwd(self, forge): + with pytest.raises(RuntimeError, match="not a git repository"): forge.issue_create("title", "body") @@ -491,3 +491,130 @@ def test_get_forge_returns_gogs_for_configured_project(self, monkeypatch, tmp_pa 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/scripts/gogs b/scripts/gogs index c95b0b05e..f83d08cf9 100755 --- a/scripts/gogs +++ b/scripts/gogs @@ -17,7 +17,9 @@ Supported commands: 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 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] @@ -421,6 +423,50 @@ def cmd_repo_view(args: List[str]) -> 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" @@ -510,6 +556,8 @@ def main() -> None: ("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, } From 4823526aef60b71c8e0dbe38ac6480ac549ae265 Mon Sep 17 00:00:00 2001 From: George Baugh Date: Thu, 4 Jun 2026 00:23:08 +0000 Subject: [PATCH 26/27] Ok maybe fix it for REALS --- CLAUDE.md | 7 ++ docs/architecture/github-and-trackers.md | 50 +++++++++++ koan/app/auto_update.py | 44 +++++++++- koan/app/branch_limiter.py | 42 +++++++-- koan/app/deep_research.py | 21 +++++ koan/app/forge/__init__.py | 20 +++++ koan/app/forge/base.py | 55 ++++++++++++ koan/app/forge/github.py | 39 +++++++++ koan/app/forge/gogs.py | 104 ++++++++++++++++++++++- koan/app/git_sync.py | 36 ++++++-- koan/app/mission_verifier.py | 53 +++++++----- koan/app/pr_feedback.py | 26 ++++++ koan/app/pr_quality.py | 27 ++++++ koan/app/pr_submit.py | 67 +++++++++++++-- koan/skills/core/add_project/handler.py | 6 +- koan/tests/test_add_project_skill.py | 32 +++---- koan/tests/test_auto_update.py | 26 +++++- koan/tests/test_branch_limiter.py | 40 ++++++++- koan/tests/test_forge_github.py | 45 ++++++++++ koan/tests/test_forge_gogs.py | 81 ++++++++++++++++++ koan/tests/test_forge_registry.py | 25 ++++++ koan/tests/test_git_sync.py | 21 +++++ koan/tests/test_mission_verifier.py | 19 +++++ koan/tests/test_pr_submit.py | 34 ++++++++ koan/tests/test_skill_add_project.py | 46 +++++----- 25 files changed, 875 insertions(+), 91 deletions(-) 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/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/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 ff64878a1..4ccd7090d 100644 --- a/koan/app/forge/__init__.py +++ b/koan/app/forge/__init__.py @@ -54,6 +54,26 @@ def get_forge(project_name: Optional[str] = None) -> ForgeProvider: 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. 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 index dd6af9bec..b8e5c6f04 100644 --- a/koan/app/forge/gogs.py +++ b/koan/app/forge/gogs.py @@ -182,6 +182,82 @@ def list_merged_prs( 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 # ------------------------------------------------------------------ @@ -287,11 +363,18 @@ def detect_fork(self, project_path: str) -> Optional[str]: p_name = parent.get("name", "") if p_owner and p_name: return f"{p_owner}/{p_name}" - except RuntimeError: - log.warning("GOGS fork detection failed for %s: %s", project_path, exc) - pass + 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 # ------------------------------------------------------------------ @@ -432,6 +515,21 @@ def _normalise_pr(data: Dict) -> Dict: } +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 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/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 8045f589c..18f886c25 100644 --- a/koan/skills/core/add_project/handler.py +++ b/koan/skills/core/add_project/handler.py @@ -62,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( @@ -207,7 +207,9 @@ def _extract_owner_repo(url): parsed = parse.urlparse(url) host = parsed.netloc if not host: - return None + # 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?://"+re.escape(host)+r"/([a-zA-Z0-9._-]+)/([a-zA-Z0-9._-]+?)(?:\.git)?$", 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 index f0ade7c97..469391757 100644 --- a/koan/tests/test_forge_gogs.py +++ b/koan/tests/test_forge_gogs.py @@ -296,6 +296,78 @@ def test_list_merged_prs_returns_empty_on_bad_response(self, forge): 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 @@ -316,6 +388,15 @@ 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 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_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) From fcb205003b116d3849252cbe79e6dac67fde0127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Thu, 4 Jun 2026 18:53:20 +0000 Subject: [PATCH 27/27] feat(review-skill): add Gogs support to /review handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect bare Gogs repo URLs via _parse_gogs_repo_url (for batch mode) - Detect Gogs PR/issue URLs via _try_extract_gogs_pr_or_issue - _handle_gogs_single: queue /review mission for a Gogs PR or issue - _handle_gogs_batch: list open PRs via GogsForge API, queue each - _list_gogs_open_prs: fetch open PRs from Gogs API, normalise to {number, title, url} shape matching the GitHub equivalent - Routing in handle() tries Gogs batch → GitHub batch → Gogs single → GitHub single; Gogs paths are tried iff KOAN_GOGS_HOST is set Co-Authored-By: Claude Sonnet 4.6 --- koan/skills/core/review/handler.py | 180 ++++++++++++++++++++++- koan/tests/test_review_handler.py | 223 +++++++++++++++++++++++++++++ 2 files changed, 399 insertions(+), 4 deletions(-) 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_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