feat(forge): add Gogs forge provider for self-hosted instances#1697
feat(forge): add Gogs forge provider for self-hosted instances#1697troglodyne-bot wants to merge 1 commit into
Conversation
|
Heh, appears our bot got a bit over its skis on this one. We'd intended the PR to go to our github organization's clone first so we could test it on our local gogs instance before sending upstream, but it seems that didn't quite work like we thought it might. We'll try and get this tested sometime tomorrow. |
|
@teodesian thanks for the fix will wait for your confirmation before merging |
Makes sense — testing against a real Gogs instance first is the right call. A few items from the code review that would be worth addressing during that testing pass:
The |
|
|
| # 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( |
There was a problem hiding this comment.
Review commentary by the Nico's bot here is legit, our little guy just gave up here after writing basically how things ought to work. I imagine it will go better for everyone if we follow the pattern with the github forge and make the subs in app.forge.gogs thin wrappers around app.gogs methods (which does not exist). For the moment I'll just fix the method and test before re-factoring.
There was a problem hiding this comment.
Agreed — the GitHubForge pattern is deliberately a thin delegation layer: GitHubForge.issue_create() → app.github.issue_create(), GitHubForge.detect_fork() → app.github.detect_parent_repo(), etc. The forge class owns the interface mapping; the underlying module (app.github) owns the logic.
For Gogs, the equivalent would be:
- Create
koan/app/gogs.pywith the actual API logic (the_api(),_raw_get(),_owner_repo_from_git_remote(),_split_repo(),_normalise_pr()helpers currently inforge/gogs.py) - Make
forge/gogs.pya thin wrapper that delegates toapp.gogsfunctions
This also fixes the immediate bug on line 167 where issue_create unpacks None without a guard — in a standalone app.gogs.issue_create() function, the git-remote resolution and error handling would be self-contained.
For the short term (getting this testable), the minimum fix is:
- Guard the
_owner_repo_from_git_remotereturn value before unpacking (it returnsNoneon failure, causingTypeError) - Make
issue_create_in_repoprivate (_issue_create_in_repo) since it's not part of theForgeProviderinterface
There was a problem hiding this comment.
Agreed on the refactor plan. The thin-wrapper pattern isn't just aesthetic — it means app.gogs functions can be used directly by other modules (e.g. claude_step.py, skill_dispatch.py) without going through the forge abstraction, which is how app.github works today.
For the immediate fix before refactoring: the issue_create crash on line 195 is the most urgent — _owner_repo_from_git_remote returns None (not a tuple) on failure, so the destructuring raises TypeError before the guard on line 196 ever runs. The fix is:
result = _owner_repo_from_git_remote(cwd)
if not result:
raise RuntimeError(...)
owner, repo_name = resultThere's also a separate crash in forge/__init__.py:74 where parsed.netloc() calls a string attribute as a method (should be parsed.netloc without parentheses) — this breaks detect_forge_from_url() entirely.
There was a problem hiding this comment.
Agreed on the refactor plan. The thin-wrapper pattern matters beyond aesthetics — app.gogs functions would be callable by claude_step.py, skill_dispatch.py, and other modules that need Gogs operations without going through the forge abstraction, which is exactly how app.github works today.
For the immediate fix before refactoring, two crashes need addressing:
-
issue_createline 195:_owner_repo_from_git_remotereturnsNone(not a tuple) on failure, so the destructuring raisesTypeErrorbefore the guard on line 196 runs. Fix: assign to a temp variable, check forNone, then unpack (same patterndetect_forkalready uses correctly at line 253). -
forge/__init__.pyline 76:parsed.netloc()calls a string attribute as a method — this crashesdetect_forge_from_url()entirely, so no Gogs URL auto-detection can work until this is fixed. Remove the parentheses:parsed.netloc.
Both are one-line fixes that should go in before testing against the real Gogs instance.
There was a problem hiding this comment.
Agreed on the thin-wrapper refactor. One additional structural concern: _owner_repo_from_git_remote() in the current code duplicates git-remote parsing that likely already exists in app.git_sync or app.github. When you extract to app.gogs, consider reusing the existing remote-parsing utility rather than maintaining a second SSH/HTTPS URL regex.
The immediate crash on line 195 is that the destructuring owner, repo_name = result executes before the if not result guard — the variable assignment and the None check are in the wrong order. The fix is three lines:
result = _owner_repo_from_git_remote(cwd)
if not result:
raise RuntimeError(...)
owner, repo_name = resultThe detect_fork method at line 271 already does this correctly, so it's just a copy-paste ordering issue.
There was a problem hiding this comment.
To be specific about what's wrong here: the code at line 195 does owner, repo_name = result where result is the return value of _owner_repo_from_git_remote(cwd). That function returns None on failure (not an empty tuple), so Python raises TypeError: cannot unpack non-iterable NoneType object before reaching the guard. The fix is to check result before unpacking — same pattern used correctly in detect_fork at line 253.
There's also a second crash in detect_fork itself: except RuntimeError: on line 259 doesn't bind the exception with as exc, but line 260 references exc. And log is never defined in this module. So detect_fork will raise NameError on any API failure.
There was a problem hiding this comment.
Agreed on the refactor priority. The thin-wrapper pattern isn't just convention — it has concrete consequences:
- Testability: Right now testing
GogsForge.pr_create()requires mockingurllib.request.urlopendeep inside the class. Withapp.gogs.pr_create()extracted, tests can mock at the module boundary. - Reuse:
claude_step.pyandskill_dispatch.pycallapp.githubfunctions directly without going through the forge abstraction. Gogs equivalents would need the same access path. - The
_owner_repo_from_git_remote()helper duplicates git-remote parsing that likely exists inapp.git_sync. Extracting toapp.gogsis the natural point to discover and reuse that.
For the immediate fix-and-test pass, the two crash bugs to prioritize are:
detect_fork()lines 258-261:logis never imported in this module andexcis unbound (missingas excin the except clause). Any fork detection failure raisesNameErrorinstead of returningNone.- Confirm
forge/__init__.pyline 74 usesparsed.netloc(property) notparsed.netloc()(method call) — the comment thread has conflicting claims about whether this bug exists in the committed code.
There was a problem hiding this comment.
To be fair, the overall structure is reasonable — Gogs API v1 is straightforward and urllib.request is the right choice to avoid adding a dependency. The issues are fixable without a rewrite: the NameError in detect_fork, the missing logging import, and the architectural refactor to app.gogs (which the author already agreed to). The bones are solid.
There was a problem hiding this comment.
Agreed on the thin-wrapper refactor. To make this concrete, here's the minimal extraction:
-
Create
koan/app/gogs.pywith the standalone functions:api()(the HTTP client),pr_create(),pr_view(),pr_diff(),list_merged_prs(),issue_create(),detect_parent_repo(),get_repo_from_remote(). These mirror theapp.githubmodule's public API. -
Move helpers there:
_split_repo(),_normalise_pr(),_owner_repo_from_git_remote(),_raw_get()become internal toapp.gogs. -
Reduce
forge/gogs.pyto ~80 lines of thin delegation (matchingforge/github.py's pattern).
This also fixes the detect_fork NameError naturally since the error handling would live in app.gogs.detect_parent_repo() with proper as exc binding.
One additional issue not yet mentioned: list_merged_prs passes type=closed which is a Gitea API parameter, not Gogs v1. Gogs silently ignores it, so it won't crash, but with limit=50 you're fetching all closed PRs (merged + rejected) and filtering client-side — merged PRs may not appear in the first page if there are many rejected ones.
There was a problem hiding this comment.
The detect_fork method at line 258 has a concrete NameError that will crash at runtime: except RuntimeError: doesn't bind as exc, but the log message references exc. Quick fix: except RuntimeError as exc:. There's also the list_merged_prs Gitea-vs-Gogs API parameter issue mentioned above.
Sounds good. Two items worth verifying on the local Gogs instance when you test:
|
Makes sense — testing against a real Gogs instance will catch the API compatibility issues that mocks can't (especially the
|
Makes sense to test against the real Gogs instance — the mocked tests can't catch API compatibility issues like the Two things to fix before testing:
|
Makes sense to test against the real instance first. Two things that will crash before you even hit the Gogs API:
Also worth checking: |
Makes sense to test against the real instance — the mocked tests can't catch Gogs v1 API divergences from Gitea. Two things to fix before that testing round:
Also worth checking: does |
Good call waiting on testing. Beyond the issues the bot already flagged (which have mostly been discussed), this review found one additional crash bug that wasn't caught in the earlier rounds:
The other main concerns are:
Nothing here that can't be fixed in a follow-up pass once the real-instance testing confirms the API behavior. |
|
Extended
Both commands infer repo from git remote if |
Makes sense — real-instance testing will catch things the mocked tests can't (Gogs v1 vs Gitea API differences, response shapes, the Beyond the issues already discussed in the inline threads, this review found one remaining crash bug that should be fixed before testing: Also worth verifying during testing: |
Makes sense to wait. Beyond the issues already flagged in the inline threads ( Also worth noting: the |
Good additions. Two things to verify during real-instance testing:
|
| "pr_created", CheckStatus.PASS, | ||
| f"PR #{pr_num}{draft_info} exists" | ||
| ) | ||
| from app.forge import get_forge_for_path |
There was a problem hiding this comment.
should not this use an abstraction on the code provider ?
it seems like we drip support for gh in favor of forge here?
There was a problem hiding this comment.
Agree. check_pr_created (line 219-224) calls run_gh("pr", "view", ...) directly. GitHubForge.find_pr_for_branch() was added in this PR — mission_verifier should delegate to forge.find_pr_for_branch(repo, branch) via get_forge_for_path(project_path). Same pattern needed for pr_submit.py lines 228-231 (run_gh("pr", "list", "--head", branch, ...)).
| """ | ||
| if self._pending_prs is not None: | ||
| return self._pending_prs | ||
| if not self._is_github_forge(): |
There was a problem hiding this comment.
looks like we want an abstraction layer for the code provider GitHub / forge
There was a problem hiding this comment.
Agreed. deep_research.py now gates on _is_github_forge(), but still calls run_gh directly inside the guarded block. Should route through forge.run_api() or equivalent. Current approach works (skips non-GitHub) but doesn't give Gogs projects issue/PR context in deep research. Worth a follow-up to add get_open_issues/get_pending_prs to ForgeProvider base class.
| ] | ||
|
|
||
|
|
||
| def _is_github_forge(project_path: str) -> bool: |
There was a problem hiding this comment.
a lot of _is_github_forge helper, seems a boilerplate, probably better abstracted and centralized
There was a problem hiding this comment.
Centralize into forge/__init__.py:
def is_github_forge(project_name=None) -> bool:
try:
return get_forge(project_name).name == "github"
except Exception:
return TrueAll four modules become a one-line import. Also eliminates inconsistent error handling (some use print(stderr), some use logging).
| from app.forge import get_forge_for_path | ||
| forge = get_forge_for_path(project_path) | ||
|
|
||
| if forge.name != "github": |
There was a problem hiding this comment.
another candidate for the abstraction layer
There was a problem hiding this comment.
pr_submit.py:61 calls origin_repo() from app.github and line 228 calls run_gh("pr", "list"). Both map to forge.repo_slug() and forge.find_pr_for_branch(). Route through get_forge_for_path(project_path) to unify.
| from app.forge import get_forge | ||
| forge = get_forge(project_name) | ||
|
|
||
| if forge.name != "github": |
There was a problem hiding this comment.
pr_submit.py is in the omitted-files list so changes aren't visible here. Per the docs update it routes through forge now. Verify submit_draft_pr calls forge.pr_create() + forge.find_pr_for_branch() instead of app.github directly. The existing resolve_submit_target fork-detection should also use forge.detect_fork().
|
@troglodyne-bot rebase look at comments and see if we can provide an abstraction layer for the two providers |
Rebase with requested adjustmentsBranch Changes applied
StatsActions performed
CI statusCI will be checked asynchronously. Automated by Kōan |
2ce4180 to
3728584
Compare
|
|
|
New commit Added Updated
Skills with explicit Gogs routing: 18 new tests + full suite (9700+) passing, lint clean. |
|
|
Abstraction layer exists via |
Skill routing looks comprehensive. Verify |
|
@troglodyne-bot rebase |
|
|
Rebase with requested adjustmentsBranch Changes applied
StatsActions performed
CI statusCI will be checked asynchronously. Automated by Kōan |
f9ff88d to
cf6b17e
Compare
Rebase landed. Note: |
|
|
|
@troglodyne-bot rebase |
Rebase with requested adjustmentsBranch Changes applied
StatsActions performed
CI statusCI will be checked asynchronously. Automated by Kōan |
9579ffe to
6f6c4ee
Compare
Rebase notes mention |
|
|
2 similar comments
|
|
|
|
|
@troglodyne-bot squash |
1047b84 to
57fa48f
Compare
Adds GogsForge implementing ForgeProvider over REST API v1 (urllib, stdlib only).
Teams running self-hosted Gogs instances now have end-to-end Kōan support:
PR creation/viewing, issue ops, open PR detection, and auto-merge routing.
Key additions:
- `koan/app/forge/gogs.py` — ForgeProvider subclass for Gogs REST API v1
- `scripts/gogs` — gh-compatible CLI wrapper (jq filter subset, no extra deps)
- Forge-aware branch limiter and PR submit paths (GitHub and Gogs both work)
- `KOAN_GOGS_HOST` / `KOAN_GOGS_TOKEN` env config + `forge: gogs` in projects.yaml
Fixes pre-existing bug: `forge/__init__.py` called non-existent `get_koan_root()`;
replaced with `os.environ.get("KOAN_ROOT")`.
58 new tests covering auth, URL parsing, forge ops (HTTP mocked), registry,
and auto-detection. Full suite: 15216 passed.
Also fixes auto_update.py to handle checkouts on non-main branches (compares
against local HEAD when main doesn't exist locally).
Squash: 35 commits → 1Branch Commit messageActions
Automated by Koan |
Squash completed per PR comments. 35→1 commit. Post-squash review findings remain: |
PR Review — feat(forge): add Gogs forge provider for self-hosted instancesSolid architecture adding Gogs as a second forge provider. One likely-critical constructor mismatch in the factory, and several broad exception catches that will hide real bugs.
🔴 Blocking1. get_forge() passes base_url= to all forge classes unconditionally (`koan/app/forge/__init__.py`, L51-53)Changed from Since GogsForge reads its host from
🟡 Important1. GitHub Enterprise detection regression (`koan/app/forge/__init__.py`, L100-101)
The Suggestion: 2. Bare except Exception hides bugs in gogs_auth (`koan/app/forge/__init__.py`, L174-186)
Narrow to 3. Overly broad exception catch in _is_github_forge (`koan/app/deep_research.py`, L175-180)Catches Narrow to 4. forge.name != 'github' branching is fragile for future forges (`koan/app/branch_limiter.py`, L56-69)The non-GitHub path calls Consider routing ALL forges through 5. Same forge.name != 'github' pattern duplicated (`koan/app/git_sync.py`, L258-280)Same structural concern as Unifying to one path through the forge eliminates ~30 lines and the risk of the two paths drifting. 🟢 Suggestions1. list_open_pr_branches delegates but changes signature semantics (`koan/app/forge/github.py`, L137-139)
2. New abstract methods raise NotImplementedError but docstrings also say 'Raises: NotImplementedError' (`koan/app/forge/base.py`, L170-215)Both Since Checklist
To rebase specific severity levels, mention me: Silent Failure Analysis🟠 **HIGH** — debug-level logging hides operational failure (`koan/app/branch_limiter.py:62-68`)Risk: Forge API failures are logged at DEBUG level (invisible at typical INFO/WARNING production logging), causing the branch limiter to silently report 0 open PRs — undermining the saturation accounting safety mechanism and potentially allowing unlimited branch creation. Fix: Log at WARNING level so operators can see when the forge API is unreachable and branch counts are incomplete. 🟠 **HIGH** — silent null return without logging (`koan/app/branch_limiter.py:55-60`)Risk: When forge.repo_slug() returns None (e.g. no origin remote, unparseable URL), the function silently returns an empty set with zero logging — the caller has no indication that open-PR counting was skipped entirely for this project. Fix: Add a log.warning when repo_slug returns empty, similar to the warning in git_sync.get_github_merged_branches which handles the same case explicitly. 🟡 **MEDIUM** — missing whitespace / style nit on critical path (`koan/app/forge/__init__.py:97-100`)Risk: Minor, but parsed=urlparse(lower) has no spaces around '=' — while not a silent failure, this is on the forge URL detection path; the real concern is the next finding. Fix: Add spaces: parsed = urlparse(lower). 🟡 **MEDIUM** — swallowed exception disables feature detection (`koan/app/forge/__init__.py:164-188`)Risk: Any exception in _gogs_host_for_detection (import error, misconfigured gogs_auth, broken env) returns empty string, permanently disabling Gogs URL auto-detection for the entire process lifetime — all Gogs URLs silently resolve to GitHub. Fix: Narrow the except to ImportError and ValueError; let unexpected errors propagate so misconfigurations surface immediately rather than silently routing Gogs traffic to GitHub. 🟡 **MEDIUM** — exception swallowed to boolean (`koan/app/deep_research.py:175-183`)Risk: If forge resolution raises (e.g. corrupt projects.yaml), _is_github_forge returns False, silently disabling issue and PR analysis for the project with no user-visible signal beyond a log line — the deep research report is incomplete without explanation. Fix: Narrow the catch to ImportError/KeyError and consider surfacing the skip reason in the research output so the user knows the analysis is partial. Automated review by Kōan (Claude · model claude-opus-4-6) |
Koan-Bot
left a comment
There was a problem hiding this comment.
Blocking issues found — see the review comment above.
|
blocked by gogs/gogs#3979 In short, we need the fork api to exist on gogs for this to work properly. I'll swing back to this once that works itself out. |
What
Adds Gogs as a supported forge provider for self-hosted instances. Includes a
gh-compatible CLI wrapper (scripts/gogs) for human use and a fullGogsForgePython implementation for programmatic access.Why
Gogs (gogs.io) is a popular lightweight self-hosted Git service with no official CLI tooling. Teams running self-hosted Gogs couldn't use Kōan for projects on those instances. This completes the Phase 2 entry in the forge roadmap (Phase 1 = GitHub, Phase 2 = Gogs, Phase 3a = GitLab).
How
koan/app/gogs_auth.py— readsKOAN_GOGS_HOSTandKOAN_GOGS_TOKENfrom envkoan/app/gogs_url_parser.py— URL parsing built at runtime fromKOAN_GOGS_HOST(Gogs uses/pulls/plural, unlike GitHub's/pull/)koan/app/forge/gogs.py—GogsForgeimplementingForgeProvider: PR create/view/list, issue create, API passthrough, fork detection, URL construction. Usesurllib.request(stdlib only, no extra deps). SupportsFEATURE_PRandFEATURE_ISSUES.scripts/gogs— standalone executable Python script with agh-compatible subcommand interface (pr create/view/diff/list,issue create/list/edit,repo view,api). Includes a minimal jq-filter subset for.[].field,length, path nav, and string concatenation.forge/registry.py+forge/__init__.py— register"gogs"type;detect_forge_from_url()picksGogsForgewhen URL matchesKOAN_GOGS_HOSTenv.example— documentsKOAN_GOGS_HOSTandKOAN_GOGS_TOKENAlso fixes a pre-existing bug:
forge/__init__.py::_resolve_forge_configcalledapp.utils.get_koan_rootwhich doesn't exist; replaced withos.environ.get("KOAN_ROOT").Testing
58 new tests in
test_forge_gogs.pycovering auth helpers, URL parser, forge operations (HTTP mocked), registry registration, and factory auto-detection. Full suite: 15216 passed, 1 pre-existing failure (timezone data missing in this env).Configuration in
projects.yaml:🤖 Generated with Claude Code