2026-05-31 Repository implementation Issue gh-forgejo-shim-ufb

Fixed Codex.app PR board compatibility for Forgejo pull requests

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.

Summary

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.

Changes Made

Normalized board fields

Added deletions, numeric additions/deletions defaults, string mergeable states, uppercased mergeStateStatus, and merged-state detection.

Accepted merged lists

gh pr list --state merged now maps to Forgejo's closed API query and filters results to pulls that actually merged.

Handled checks probes

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.

Context

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.

Important Implementation Details

Relevant Diff Snippets

Rendered with Diffs server-side rendering via @pierre/diffs/ssr, using preloadPatchFile. Diffs docs: https://diffs.com/docs.

src/gh_forgejo_shim/normalize.py
-4+7
3 unmodified lines
4
5
6
7
8
9
27 unmodified lines
37
38
39
40
41
42
43
44
45
46
47
48
4 unmodified lines
53
54
55
56
57
58
59
60
3 unmodified lines
SUPPORTED_JSON_FIELDS = (
"additions",
"number",
"title",
"state",
27 unmodified lines
user = 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 lines
4
5
6
7
8
9
10
27 unmodified lines
38
39
40
41
42
43
44
45
46
47
48
49
50
51
4 unmodified lines
56
57
58
59
60
61
62
63
3 unmodified lines
SUPPORTED_JSON_FIELDS = (
"additions",
"deletions",
"number",
"title",
"state",
27 unmodified lines
user = 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 [],
src/gh_forgejo_shim/routing.py
-2+16
27 unmodified lines
28
29
30
31
32
108 unmodified lines
143
144
145
146
147
148
84 unmodified lines
233
234
235
236
237
238
239
12 unmodified lines
252
253
254
255
256
257
27 unmodified lines
parse_repo_spec,
)
SUPPORTED_PR_COMMANDS = {"create", "list", "new", "status", "view"}
SUPPORTED_REPO_COMMANDS = {"view"}
108 unmodified lines
rest = 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 lines
target_repo = parse_repo_spec(parsed.repo, default_host=repo.host) if parsed.repo else repo
if 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 = [
pull
12 unmodified lines
return 0
def _run_view(
argv: list[str],
repo: RepoRef,
27 unmodified lines
28
29
30
31
32
108 unmodified lines
143
144
145
146
147
148
149
150
84 unmodified lines
235
236
237
238
239
240
241
242
243
244
12 unmodified lines
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
27 unmodified lines
parse_repo_spec,
)
SUPPORTED_PR_COMMANDS = {"checks", "create", "list", "new", "status", "view"}
SUPPORTED_REPO_COMMANDS = {"view"}
108 unmodified lines
rest = 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 lines
target_repo = parse_repo_spec(parsed.repo, default_host=repo.host) if parsed.repo else repo
if target_repo is None:
raise ValueError("could not parse --repo value")
api_state = "closed" if parsed.state == "merged" else parsed.state
pulls = 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 = [
pull
12 unmodified lines
return 0
def _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 0
def _run_view(
argv: list[str],
repo: RepoRef,

Expected Impact for End-Users

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.

Validation

Issues, Limitations, and Mitigations

Follow-up Work

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.