Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
877eebc
fix(awake): don't sys.exit(1) on non-telegram messaging providers
troglodyne Jun 4, 2026
1ca6952
fix(matrix): mint Telegram-shaped wrapper in HTTP fallback + dynamic …
troglodyne Jun 4, 2026
bc04584
feat(forge): add Gogs forge provider for self-hosted instances
troglodyne-bot Jun 3, 2026
b87b1f1
Reformat/clarify env.example a bit
teodesian Jun 3, 2026
65a5f83
Change example in projects.example.yaml to prefer forge_url
teodesian Jun 3, 2026
bd09bc7
pass base_url to all forge classes, not just GitHubForge
teodesian Jun 3, 2026
1e09648
Implement issue_create in gogs forge
teodesian Jun 3, 2026
4823df1
Do stricter check against repo uri when discerning what forge to load
teodesian Jun 3, 2026
365f8fe
Revert "pass base_url to all forge classes, not just GitHubForge"
teodesian Jun 3, 2026
698e4bb
If we have a forge url attempt to pass it to the forge class.
teodesian Jun 3, 2026
685cbd1
better? guard against incapacity to determine repo information in gog…
teodesian Jun 3, 2026
5d4a17d
Blow up in _api and _raw_get when token is not set in gogs forge
teodesian Jun 3, 2026
5a2495e
warn on failure to get gogs host in koan/app/forge/__init__.py
teodesian Jun 3, 2026
85fcd2d
warn in the event KOAN_ROOT can't be determined by forges
teodesian Jun 3, 2026
c4a59c6
warn in the event fork detection on gogs fails
teodesian Jun 3, 2026
666d5e4
throw in the event we can't discern the URL of a PR created in GOGS
teodesian Jun 3, 2026
e8aa4fd
forgot netloc was an attr, not a method
teodesian Jun 3, 2026
7944a5a
remove some semicolon perlisms
teodesian Jun 3, 2026
c91f0be
Don't break api.github.com, etc when determining repo urls are good
teodesian Jun 3, 2026
a2f6aa2
live a bit more dangerously RE requiring a token with GOGS repos.
teodesian Jun 3, 2026
85bf495
Revert "Change example in projects.example.yaml to prefer forge_url"
teodesian Jun 3, 2026
4cc9b37
Add a note that forge_url is not to be trusted in any way
teodesian Jun 3, 2026
812a5db
import logger module in gogs forge module
teodesian Jun 3, 2026
5afa1c4
Understand gogs uris in handler.py
teodesian Jun 3, 2026
e73ccc1
feat(forge/gogs): add repo permissions and fork commands to scripts/gogs
troglodyne-bot Jun 3, 2026
4823526
Ok maybe fix it for REALS
teodesian Jun 4, 2026
fcb2050
feat(review-skill): add Gogs support to /review handler
troglodyne-bot Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`)
Expand Down
50 changes: 50 additions & 0 deletions docs/architecture/github-and-trackers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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.
Expand Down
24 changes: 22 additions & 2 deletions env.example
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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)
# =========================================================================
Expand Down Expand Up @@ -76,6 +83,10 @@
# KOAN_CLI_PROVIDER=claude
# Note: CLI_PROVIDER (without KOAN_ prefix) is also supported for backward compatibility

# =========================================================================
# GITHUB CONFIGURATION (default mode of operation for finding repos)
# =========================================================================

# GitHub CLI identity
# If set, gh CLI commands will run as this user (via gh auth token --user).
# The user must be pre-authenticated with: gh auth login --user <username>
Expand All @@ -89,6 +100,15 @@
# See docs/messaging/github-webhooks.md for the full setup (tunnel + webhook config).
# KOAN_GITHUB_WEBHOOK_SECRET=

# KOAN_BRIDGE_INTERVAL=3 # Telegram poll interval in seconds (default: 3)
# =========================================================================
# GOGS CONFIGURATION (optional — for self-hosted Gogs instances)
# =========================================================================
# Set forge: gogs in projects.yaml for any project hosted on your Gogs server.
# The scripts/gogs CLI wrapper also reads these at runtime.

# Base URL of your Gogs instance (no trailing slash)
# KOAN_GOGS_HOST=https://git.example.com

# Personal access token — generate one at: https://<your-gogs>/user/settings/applications
# KOAN_GOGS_TOKEN=your-token-here

# Note: max_runs_per_day and interval_seconds are configured in config.yaml
44 changes: 42 additions & 2 deletions koan/app/auto_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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..<remote>/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:
Expand All @@ -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.

Expand Down
24 changes: 19 additions & 5 deletions koan/app/awake.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -727,7 +732,8 @@ def main():

setup_github_auth()

provider_name = "telegram" # about to become dynamic with provider abstraction
from app.messaging import _resolve_provider_name
provider_name = _resolve_provider_name()
print_bridge_banner(f"messaging bridge — {provider_name.lower()}")

# Record startup time — used to ignore stale signal files in the
Expand All @@ -743,8 +749,10 @@ def main():
heartbeat_file = KOAN_ROOT / HEARTBEAT_FILE
heartbeat_file.unlink(missing_ok=True)
write_heartbeat(str(KOAN_ROOT))
log("init", f"Token: ...{BOT_TOKEN[-8:]}")
log("init", f"Chat ID: {CHAT_ID}")
if BOT_TOKEN:
log("init", f"Token: ...{BOT_TOKEN[-8:]}")
if CHAT_ID:
log("init", f"Chat ID: {CHAT_ID}")
log("init", f"Soul: {len(SOUL)} chars loaded")
log("init", f"Summary: {len(SUMMARY)} chars loaded")
registry = _get_registry()
Expand Down Expand Up @@ -811,7 +819,13 @@ def main():
msg = update.get("message", {})
text = msg.get("text", "")
chat_id = str(msg.get("chat", {}).get("id", ""))
if chat_id == CHAT_ID and text:
# Match against either: (a) the active provider's channel
# id (resolved at startup — covers slack/matrix where
# CHAT_ID is unset), or (b) CHAT_ID (telegram-only, kept
# for backward compat with existing tests that patch it
# directly). For telegram in production the two are the
# same value.
if text and chat_id in (str(channel_id), str(CHAT_ID)):
message_id = msg.get("message_id", 0)
text = _strip_bot_mention_from_text(text, msg)
log("chat", f"Received: {text[:60]}")
Expand Down
42 changes: 37 additions & 5 deletions koan/app/branch_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
21 changes: 21 additions & 0 deletions koan/app/deep_research.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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.

Expand Down
Loading
Loading