Map Forgejo CI Status Into GitHub-Shaped Output
Forgejo commit statuses now feed the shim's GitHub-compatible PR status surfaces, so tools that ask for statusCheckRollup or gh pr checks receive useful check data instead of an empty placeholder.
gh-forgejo-shim-wk9unittest; compileall passed.Summary
Added commit-status support for Forgejo pull requests and translated those statuses into GitHub CLI-shaped check rows and rollup items.
Changes Made
- Added
ForgejoClient.list_commit_statusesfor/statuses/{sha}responses. - Added normalizers for
statusCheckRollupobjects andgh pr checks --jsonrows. - Updated PR list, view, status, and checks routing to fetch statuses from the PR head SHA when needed.
- Updated tests to verify rollup output, check row output, and repeated-status de-duplication by context.
- Updated the README to document status/check JSON behavior.
Context
GitHub-oriented tools often probe gh pr list, gh pr status, or gh pr checks to decide whether a branch is ready. Before this turn, the shim accepted those probes but did not translate Forgejo CI/status data into the fields those tools expect.
Important Implementation Details
- Status data is fetched only when
statusCheckRollupis requested or whengh pr checksruns, keeping ordinary PR calls cheap. - Forgejo states are mapped into stable buckets:
successbecomespass, failures and errors becomefail, and everything else becomespending. - When Forgejo returns repeated status updates for a context, the latest timestamp wins.
- If a pull request has no head SHA, the shim returns an empty status/check list instead of failing.
Relevant Diff Snippets
This is a focused server-rendered excerpt from the core implementation files.
63 unmodified lines64656667686963 unmodified linesreturn []return [item for item in files if isinstance(item, dict)]def get_repo(self, repo: RepoRef) -> dict[str, Any]:return self._request_json("GET", repo.api_base_url, None)63 unmodified lines646566676869707172737475767763 unmodified linesreturn []return [item for item in files if isinstance(item, dict)]def list_commit_statuses(self, repo: RepoRef, sha: str) -> list[dict[str, Any]]:statuses = self._request_json("GET", f"{repo.api_base_url}/statuses/{_quote(sha)}", None)if isinstance(statuses, dict) and isinstance(statuses.get("statuses"), list):statuses = statuses["statuses"]if not isinstance(statuses, list):return []return [item for item in statuses if isinstance(item, dict)]def get_repo(self, repo: RepoRef) -> dict[str, Any]:return self._request_json("GET", repo.api_base_url, None)
115 unmodified lines11611711811912012121 unmodified lines143144145146147148149150151152180 unmodified lines333334335336337338115 unmodified lines"url",)def normalize_pull(pull: dict[str, Any]) -> dict[str, Any]:user = pull.get("user") if isinstance(pull.get("user"), dict) else {}21 unmodified lines"mergeStateStatus": str(pull.get("merge_state_status") or "UNKNOWN").upper(),"reviewDecision": pull.get("reviewDecision"),"reviewRequests": pull.get("reviewRequests") or [],"statusCheckRollup": pull.get("statusCheckRollup") or [],}def _normalize_count(value: Any) -> int:if isinstance(value, bool):return 0180 unmodified lines}def _is_organization(owner: dict[str, Any]) -> bool:owner_type = str(owner.get("type") or "").lower()return owner_type in {"organization", "org"}115 unmodified lines11611711811912012112212312412512612712812913013113213313421 unmodified lines156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185180 unmodified lines366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484115 unmodified lines"url",)SUPPORTED_CHECK_JSON_FIELDS = ("bucket","completedAt","conclusion","description","detailsUrl","link","name","startedAt","state","workflow",)def normalize_pull(pull: dict[str, Any]) -> dict[str, Any]:user = pull.get("user") if isinstance(pull.get("user"), dict) else {}21 unmodified lines"mergeStateStatus": str(pull.get("merge_state_status") or "UNKNOWN").upper(),"reviewDecision": pull.get("reviewDecision"),"reviewRequests": pull.get("reviewRequests") or [],"statusCheckRollup": _normalize_status_check_rollup(pull.get("statusCheckRollup")),}def with_status_check_rollup(pull: dict[str, Any], statuses: list[dict[str, Any]]) -> dict[str, Any]:enriched = dict(pull)enriched["statusCheckRollup"] = normalize_status_check_rollup(statuses)return enricheddef normalize_status_check_rollup(statuses: list[dict[str, Any]]) -> list[dict[str, Any]]:return [_status_to_rollup_item(status) for status in _latest_statuses(statuses)]def normalize_pr_checks(statuses: list[dict[str, Any]]) -> list[dict[str, Any]]:return [_status_to_check_item(status) for status in _latest_statuses(statuses)]def filter_check_fields(data: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:if not fields:return datareturn {field: data.get(field) for field in fields if field in SUPPORTED_CHECK_JSON_FIELDS}def _normalize_count(value: Any) -> int:if isinstance(value, bool):return 0180 unmodified lines}def _normalize_status_check_rollup(value: Any) -> list[dict[str, Any]]:if not isinstance(value, list):return []return [item for item in value if isinstance(item, dict)]def _latest_statuses(statuses: list[dict[str, Any]]) -> list[dict[str, Any]]:latest: dict[str, dict[str, Any]] = {}for status in statuses:key = _status_name(status)existing = latest.get(key)if existing is None or _status_timestamp(status) >= _status_timestamp(existing):latest[key] = statusreturn sorted(latest.values(), key=_status_name)def _status_to_rollup_item(status: dict[str, Any]) -> dict[str, Any]:state = _status_state(status)target_url = _status_url(status)return {"__typename": "StatusContext","completedAt": _status_completed_at(status, state),"conclusion": _status_conclusion(state),"context": _status_name(status),"description": status.get("description"),"detailsUrl": target_url,"name": _status_name(status),"startedAt": status.get("created_at"),"state": state,"targetUrl": target_url,"workflowName": _status_workflow(status),}def _status_to_check_item(status: dict[str, Any]) -> dict[str, Any]:state = _status_state(status)target_url = _status_url(status)return {"bucket": _status_bucket(state),"completedAt": _status_completed_at(status, state),"conclusion": _status_conclusion(state),"description": status.get("description"),"detailsUrl": target_url,"link": target_url,"name": _status_name(status),"startedAt": status.get("created_at"),"state": _check_state(state),"workflow": _status_workflow(status),}def _status_name(status: dict[str, Any]) -> str:context = status.get("context") or status.get("name") or status.get("title")return str(context) if context else "status"def _status_workflow(status: dict[str, Any]) -> str:workflow = status.get("workflow") or status.get("workflow_name") or status.get("context")return str(workflow) if workflow else _status_name(status)def _status_url(status: dict[str, Any]) -> str | None:url = status.get("target_url") or status.get("url") or status.get("html_url")return url if isinstance(url, str) and url else Nonedef _status_timestamp(status: dict[str, Any]) -> str:value = status.get("updated_at") or status.get("created_at")return value if isinstance(value, str) else ""def _status_state(status: dict[str, Any]) -> str:raw = str(status.get("state") or status.get("status") or "").lower()if raw in {"success", "successful", "ok", "pass", "passed"}:return "SUCCESS"if raw in {"failure", "failed"}:return "FAILURE"if raw in {"error", "cancelled", "canceled", "warning"}:return "ERROR"return "PENDING"def _status_conclusion(state: str) -> str | None:if state == "SUCCESS":return "success"if state == "FAILURE":return "failure"if state == "ERROR":return "failure"return Nonedef _status_bucket(state: str) -> str:if state == "SUCCESS":return "pass"if state in {"FAILURE", "ERROR"}:return "fail"return "pending"def _check_state(state: str) -> str:if state in {"SUCCESS", "FAILURE", "ERROR"}:return "completed"return "pending"def _status_completed_at(status: dict[str, Any], state: str) -> str | None:if state not in {"SUCCESS", "FAILURE", "ERROR"}:return Nonevalue = status.get("updated_at") or status.get("created_at")return value if isinstance(value, str) else Nonedef _is_organization(owner: dict[str, Any]) -> bool:owner_type = str(owner.get("type") or "").lower()return owner_type in {"organization", "org"}
14 unmodified lines15161718192021222324252627125 unmodified lines153154155156157158159124 unmodified lines2842852862872882892902 unmodified lines293294295296297298299300301302303304161 unmodified lines46646746846947047147253 unmodified lines526527528529530531532947 unmodified lines148014811482148314841485143 unmodified lines1629163016311632163316345 unmodified lines16401641164216431644164514 unmodified linesfrom .external import find_program, git_run, run_programfrom .forgejo import ForgejoClient, ForgejoError, RepoReffrom .normalize import (filter_issue_fields,filter_fields,filter_repo_fields,normalize_issue,normalize_pull,normalize_repo,status_for_current_branch,)from .repo import (current_branch,125 unmodified linesif 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 in {"checkout", "co"}:return _run_checkout(rest, repo, client, cwd=cwd, stdout=stdout, stderr=stderr)if command == "comment":124 unmodified linesif parsed.limit is not None:pulls = pulls[: parsed.limit]normalized = [filter_fields(normalize_pull(pull), parsed.json_fields) for pull in pulls]if parsed.json_fields:_print_json_or_jq_list(normalized, parsed.jq, stdout)else:2 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 0161 unmodified lines_print_json_or_jq({}, parsed.jq, stdout)return 0normalized = normalize_pull(pull)if parsed.web:url = str(normalized.get("url") or "")if url:53 unmodified linesreturn 0if parsed.json_fields:_print_json_or_jq(status_for_current_branch(pull, parsed.json_fields), parsed.jq, stdout)else:normalized = normalize_pull(pull)print(_format_pull_text(normalized), file=stdout)947 unmodified linesreturn f"{number}\t{title}\t{state}".rstrip()def _print_json_or_jq(data: dict[str, object], jq: str | None, stdout: TextIO) -> None:if jq:value = _apply_simple_jq(data, jq)143 unmodified linesreturn pulls[0] if pulls else Nonedef _pull_number(pull: dict[str, object]) -> int | None:raw = pull.get("number") or pull.get("id")if isinstance(raw, bool):5 unmodified linesreturn Nonedef _pull_head_ref(pull: dict[str, object]) -> str | None:head = pull.get("head")if not isinstance(head, dict):14 unmodified lines15161718192021222324252627282930125 unmodified lines156157158159160161162124 unmodified lines2872882892902912922932942952962 unmodified lines299300301302303304305306307308309310311312313314315316317318319320321322323324325326327161 unmodified lines48949049149249349449553 unmodified lines549550551552553554555556947 unmodified lines1504150515061507150815091510151115121513151415151516143 unmodified lines16601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316845 unmodified lines1690169116921693169416951696169716981699170017011702170314 unmodified linesfrom .external import find_program, git_run, run_programfrom .forgejo import ForgejoClient, ForgejoError, RepoReffrom .normalize import (filter_check_fields,filter_issue_fields,filter_fields,filter_repo_fields,normalize_pr_checks,normalize_issue,normalize_pull,normalize_repo,status_for_current_branch,with_status_check_rollup,)from .repo import (current_branch,125 unmodified linesif command in {"create", "new"}:return _run_create(rest, repo, client, cwd=cwd, stdout=stdout, stdin=stdin)if command == "checks":return _run_checks(rest, repo, client, cwd=cwd, stdout=stdout)if command in {"checkout", "co"}:return _run_checkout(rest, repo, client, cwd=cwd, stdout=stdout, stderr=stderr)if command == "comment":124 unmodified linesif parsed.limit is not None:pulls = pulls[: parsed.limit]normalized = [filter_fields(normalize_pull(_enrich_pull_statuses(target_repo, client, pull, parsed.json_fields)), parsed.json_fields)for pull in pulls]if parsed.json_fields:_print_json_or_jq_list(normalized, parsed.jq, stdout)else:2 unmodified linesreturn 0def _run_checks(argv: list[str],repo: RepoRef,client: ForgejoClient,*,cwd: str | None,stdout: TextIO,) -> int:parsed = _parse_checks_args(argv)target_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")pull = _resolve_pull(target_repo, client, number=parsed.number, branch=None, cwd=cwd)statuses = [] if pull is None else _pull_statuses(target_repo, client, pull)checks = [filter_check_fields(check, parsed.json_fields) for check in normalize_pr_checks(statuses)]if parsed.json_fields:_print_json_or_jq_list(checks, parsed.jq, stdout)else:if not checks:print("no checks reported", file=stdout)for check in checks:print(_format_check_item(check), file=stdout)return 0161 unmodified lines_print_json_or_jq({}, parsed.jq, stdout)return 0normalized = normalize_pull(_enrich_pull_statuses(repo, client, pull, parsed.json_fields))if parsed.web:url = str(normalized.get("url") or "")if url:53 unmodified linesreturn 0if parsed.json_fields:enriched = _enrich_pull_statuses(repo, client, pull, parsed.json_fields)_print_json_or_jq(status_for_current_branch(enriched, parsed.json_fields), parsed.jq, stdout)else:normalized = normalize_pull(pull)print(_format_pull_text(normalized), file=stdout)947 unmodified linesreturn f"{number}\t{title}\t{state}".rstrip()def _format_check_item(data: dict[str, object]) -> str:bucket = data.get("bucket") or "pending"name = data.get("name") or "status"description = data.get("description") or ""return f"{bucket}\t{name}\t{description}".rstrip()def _print_json_or_jq(data: dict[str, object], jq: str | None, stdout: TextIO) -> None:if jq:value = _apply_simple_jq(data, jq)143 unmodified linesreturn pulls[0] if pulls else Nonedef _enrich_pull_statuses(repo: RepoRef,client: ForgejoClient,pull: dict[str, object],fields: tuple[str, ...],) -> dict[str, object]:if "statusCheckRollup" not in fields:return pullstatuses = _pull_statuses(repo, client, pull)return with_status_check_rollup(pull, statuses)def _pull_statuses(repo: RepoRef, client: ForgejoClient, pull: dict[str, object]) -> list[dict[str, object]]:sha = _pull_head_sha(pull)if sha is None:return []return client.list_commit_statuses(repo, sha)def _pull_number(pull: dict[str, object]) -> int | None:raw = pull.get("number") or pull.get("id")if isinstance(raw, bool):5 unmodified linesreturn Nonedef _pull_head_sha(pull: dict[str, object]) -> str | None:head = pull.get("head")if not isinstance(head, dict):return Nonesha = head.get("sha")return sha if isinstance(sha, str) and sha else Nonedef _pull_head_ref(pull: dict[str, object]) -> str | None:head = pull.get("head")if not isinstance(head, dict):
Expected Impact for End-Users
Codex, T3, editors, and bots that expect GitHub-style PR checks should now see meaningful Forgejo status data for allowlisted repositories, including pass, fail, and pending check rows.
Validation
PYTHONPATH=src python3 -m unittest discover -s tests: 74 tests passed.python3 -m compileall -q src tests: passed.python3 -m pytest -q: not available because the active Python environment has nopytestmodule.
Issues, Limitations, and Mitigations
bd dolt pullfailed because local and remote Dolt histories have no common ancestor. I left the local Beads issue in place and did not run destructive recovery.- The mapping uses Forgejo commit statuses. If a Forgejo instance exposes richer Actions jobs separately without commit statuses, those jobs will not appear until the instance publishes statuses or the shim adds a second API source.
- The rollup shape is intentionally practical rather than a complete GitHub GraphQL object model.
Follow-up Work
- Resolve Beads Dolt divergence before relying on cross-machine issue sync.
- Smoke test against a live Forgejo repository with passing, failing, and pending statuses.
- Add a compatibility note if a specific editor expects additional
gh pr checksfields.