Normalized board fields
Added deletions, numeric additions/deletions defaults, string mergeable states, uppercased mergeStateStatus, and merged-state detection.
Codex.app now gets GitHub-shaped pull request data from the Forgejo shim for the PR board, current branch status, merged PR lookups, and PR checks probes.
Codex.app had been reaching Checking pull request and then falling back to Pull request status unavailable. The installed shim now returns parse-safe GitHub CLI-compatible data for the PR board path, and the user confirmed the Codex.app sidebar now resolves the Forgejo pull request.
Added deletions, numeric additions/deletions defaults, string mergeable states, uppercased mergeStateStatus, and merged-state detection.
gh pr list --state merged now maps to Forgejo's closed API query and filters results to pulls that actually merged.
gh pr checks --json ... now returns an empty JSON list instead of failing, which is enough for Codex to treat the command as supported when Forgejo has no GitHub-style checks payload.
The Codex app bundle showed that its native PR workflow asks gh pr list for board fields such as additions, deletions, mergeable, mergeStateStatus, and statusCheckRollup, including an additional merged PR query. Before this change, the shim returned missing or incompatible values for some of those fields and rejected --state merged.
The fix was tested against the real Islandflow Forgejo repository on branch lavender/all-quiet, where PR #16 is the current branch pull request.
mergeable: false from Forgejo is normalized to UNKNOWN, because GitHub CLI consumers expect string enum-like values rather than booleans.0, so strict JSON schemas do not reject null values.merged or merged_at, then emitted as MERGED.Rendered with Diffs server-side rendering via @pierre/diffs/ssr, using preloadPatchFile. Diffs docs: https://diffs.com/docs.
3 unmodified lines45678927 unmodified lines3738394041424344454647484 unmodified lines53545556575859603 unmodified linesSUPPORTED_JSON_FIELDS = ("additions","number","title","state",27 unmodified linesuser = pull.get("user") if isinstance(pull.get("user"), dict) else {}head = pull.get("head") if isinstance(pull.get("head"), dict) else {}base = pull.get("base") if isinstance(pull.get("base"), dict) else {}state = pull.get("state")return {"number": pull.get("number") or pull.get("id"),"title": pull.get("title"),"state": str(state).upper() if state is not None else None,"isDraft": bool(pull.get("draft", False)),"url": pull.get("html_url") or pull.get("url"),"headRefName": head.get("ref"),4 unmodified lines},"createdAt": pull.get("created_at"),"updatedAt": pull.get("updated_at"),"mergeable": pull.get("mergeable"),"mergeStateStatus": pull.get("merge_state_status") or "UNKNOWN","reviewDecision": pull.get("reviewDecision"),"reviewRequests": pull.get("reviewRequests") or [],"statusCheckRollup": pull.get("statusCheckRollup") or [],3 unmodified lines4567891027 unmodified lines38394041424344454647484950514 unmodified lines56575859606162633 unmodified linesSUPPORTED_JSON_FIELDS = ("additions","deletions","number","title","state",27 unmodified linesuser = pull.get("user") if isinstance(pull.get("user"), dict) else {}head = pull.get("head") if isinstance(pull.get("head"), dict) else {}base = pull.get("base") if isinstance(pull.get("base"), dict) else {}state = _normalize_state(pull)return {"additions": _normalize_count(pull.get("additions")),"deletions": _normalize_count(pull.get("deletions")),"number": pull.get("number") or pull.get("id"),"title": pull.get("title"),"state": state,"isDraft": bool(pull.get("draft", False)),"url": pull.get("html_url") or pull.get("url"),"headRefName": head.get("ref"),4 unmodified lines},"createdAt": pull.get("created_at"),"updatedAt": pull.get("updated_at"),"mergeable": _normalize_mergeable(pull),"mergeStateStatus": str(pull.get("merge_state_status") or "UNKNOWN").upper(),"reviewDecision": pull.get("reviewDecision"),"reviewRequests": pull.get("reviewRequests") or [],"statusCheckRollup": pull.get("statusCheckRollup") or [],
27 unmodified lines2829303132108 unmodified lines14314414514614714884 unmodified lines23323423523623723823912 unmodified lines25225325425525625727 unmodified linesparse_repo_spec,)SUPPORTED_PR_COMMANDS = {"create", "list", "new", "status", "view"}SUPPORTED_REPO_COMMANDS = {"view"}108 unmodified linesrest = argv[2:]if command in {"create", "new"}:return _run_create(rest, repo, client, cwd=cwd, stdout=stdout, stdin=stdin)if command == "list":return _run_list(rest, repo, client, stdout=stdout)if command == "view":84 unmodified linestarget_repo = parse_repo_spec(parsed.repo, default_host=repo.host) if parsed.repo else repoif target_repo is None:raise ValueError("could not parse --repo value")pulls = client.list_pulls(target_repo, state=parsed.state, head=parsed.head)if parsed.base:pulls = [pull12 unmodified linesreturn 0def _run_view(argv: list[str],repo: RepoRef,27 unmodified lines2829303132108 unmodified lines14314414514614714814915084 unmodified lines23523623723823924024124224324412 unmodified lines25725825926026126226326426526626726826927027127 unmodified linesparse_repo_spec,)SUPPORTED_PR_COMMANDS = {"checks", "create", "list", "new", "status", "view"}SUPPORTED_REPO_COMMANDS = {"view"}108 unmodified linesrest = argv[2:]if command in {"create", "new"}:return _run_create(rest, repo, client, cwd=cwd, stdout=stdout, stdin=stdin)if command == "checks":return _run_checks(rest, stdout=stdout)if command == "list":return _run_list(rest, repo, client, stdout=stdout)if command == "view":84 unmodified linestarget_repo = parse_repo_spec(parsed.repo, default_host=repo.host) if parsed.repo else repoif target_repo is None:raise ValueError("could not parse --repo value")api_state = "closed" if parsed.state == "merged" else parsed.statepulls = client.list_pulls(target_repo, state=api_state, head=parsed.head)if parsed.state == "merged":pulls = [pull for pull in pulls if normalize_pull(pull).get("state") == "MERGED"]if parsed.base:pulls = [pull12 unmodified linesreturn 0def _run_checks(argv: list[str], *, stdout: TextIO) -> int:parsed = _parse_checks_args(argv)if parsed.json_fields:_print_json_or_jq_list([], parsed.jq, stdout)else:print("no checks reported", file=stdout)return 0def _run_view(argv: list[str],repo: RepoRef,
Codex.app can now surface Forgejo pull request status through its native GitHub workflow when the shim is first in PATH. In the tested Islandflow thread, the PR status moved from unavailable to working after the installed shim was updated and Codex.app rechecked the PR.
PYTHONPATH=/Users/kell/dev/gh-forgejo-shim/src python3 -m unittest: passed, 57 tests.git diff --check: passed.pipx install --force /Users/kell/dev/gh-forgejo-shim: updated the shim installation used by /Users/kell/.local/bin/gh.gh-forgejo-shim doctor in Islandflow: confirmed the shim is first in terminal and macOS GUI PATH.gh pr list --state open, gh pr list --state merged, gh pr status --json ..., gh pr checks 16 --json ..., gh pr view 16 --json ..., --jq .currentBranch.url, and --head lavender/all-quiet.[] for pr checks. This avoids Codex parse failures, but it does not display real Forgejo CI checks yet.UNKNOWN rather than pretending the PR is mergeable.No required follow-up issue was filed. A useful future enhancement would be mapping Forgejo CI or status endpoints into GitHub-style gh pr checks rows if Codex.app starts displaying those details in the sidebar.