Pull request probes
gh pr diff, gh pr comment, gh pr checkout, and gh pr checks now parse the common flags tools use. Diffs and comments call Forgejo API endpoints; checkout fetches the PR head through Git.
The shim now handles the next practical GitHub CLI command surface for Forgejo repositories: PR checks, diffs, comments, checkout, basic issue workflows, and richer repository JSON.
Added Forgejo-backed support for common developer-tool probes around pull requests, issues, and repository metadata. The implementation keeps the existing focused-shim posture: allowlisted Forgejo repositories get GitHub-shaped responses, while unsupported commands continue to delegate to the real GitHub CLI.
gh pr diff, gh pr comment, gh pr checkout, and gh pr checks now parse the common flags tools use. Diffs and comments call Forgejo API endpoints; checkout fetches the PR head through Git.
gh issue list, gh issue view, gh issue create, plus ls and new aliases now route for allowlisted Forgejo hosts and return GitHub-shaped issue JSON.
gh repo view --json ... now recognizes a broad set of GitHub CLI fields such as visibility, primaryLanguage, viewerPermission, merge method booleans, counts, and repository feature flags.
Added PRODUCT.md because the repository turn-document styling workflow now requires an explicit product-register context before using impeccable as the design authority.
The previous focused PR and repository support was enough for core Codex.app PR status, but GitHub-oriented tools probe a wider set of commands once the basic repository shape is healthy. This work covers the next commands the user identified: gh pr checks, gh pr diff, gh pr comment, gh pr checkout, gh issue list/view/create, and richer gh repo view --json fields.
Forgejo's current OpenAPI document exposes matching endpoints for pull diffs, issue comments, repository issues, issue creation, labels, and repository metadata. The implementation uses those endpoints directly and keeps unsupported GitHub-only behaviors explicit.
ForgejoClient now has text and JSON request paths, so diff and patch endpoints do not have to masquerade as JSON.gh pr diff --name-only uses Forgejo's changed-files endpoint and supports repeated --exclude patterns.gh pr checkout fetches the pull head branch from origin, then falls back to refs/pull/<number>/head. It supports local branch names, force checkout, detach mode, and recursive submodules.gh pr comment posts to Forgejo's issue comment endpoint because Forgejo pull requests are issue-backed.gh issue create --label resolves label names to Forgejo label IDs before creating the issue, and numeric label IDs still pass through.owner/repo path instead of accidentally treating trailing issues/13 or pulls/7 segments as the repository.Rendered with Diffs server-side rendering via @pierre/diffs/ssr and preloadPatchFile, following diffs.com/docs. The saved document contains static diff markup and does not need JavaScript, a CDN, or remote modules to be readable.
73 unmodified lines747576777879808135 unmodified lines1171181191201211225 unmodified lines12812913013113213313413513613713829 unmodified lines16816917017117217317417517617717873 unmodified lines- Repository discovery from local Git remotes.- Default branch discovery through `origin/HEAD`.- Current branch and upstream tracking for branch status, ahead/behind counts, and commit or push UI.- Repository metadata through `gh repo view`.- Pull request creation, listing, viewing, and current-branch status through `gh pr ...`.- GUI-launched macOS tools that need a usable PATH to find the shim and the real `gh`.- Forgejo auth discovery from environment variables or common `fj`, `tea`, and `gitea` config files.35 unmodified linesgit status --short --branchgh repo view --json nameWithOwner,url,defaultBranchRef,sshUrlgh pr status --json number,title,url,headRefName,state````gfj bootstrap` checks these same basics and prints the matching commands when something is missing.5 unmodified linesOnly these commands are routed for allowlisted Forgejo repositories:```shgh pr creategh pr listgh pr newgh pr statusgh pr viewgh repo view```29 unmodified linesstatusCheckRollup````gh repo view --json ...` supports a small repository metadata subset:```textdescription, defaultBranchRef, isPrivate, name, nameWithOwner,owner, sshUrl, url````gh pr status --json ...` follows the GitHub CLI status envelope:73 unmodified lines74757677787980818235 unmodified lines1181191201211221231245 unmodified lines13013113213313413513613713813914014114214314414514614714829 unmodified lines17817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021173 unmodified lines- Repository discovery from local Git remotes.- Default branch discovery through `origin/HEAD`.- Current branch and upstream tracking for branch status, ahead/behind counts, and commit or push UI.- Repository metadata through `gh repo view`, including a broad set of GitHub-shaped JSON fields.- Pull request creation, listing, viewing, current-branch status, diffs, comments, checks, and checkout through `gh pr ...`.- Basic issue listing, viewing, and creation through `gh issue ...`.- GUI-launched macOS tools that need a usable PATH to find the shim and the real `gh`.- Forgejo auth discovery from environment variables or common `fj`, `tea`, and `gitea` config files.35 unmodified linesgit status --short --branchgh repo view --json nameWithOwner,url,defaultBranchRef,sshUrlgh pr status --json number,title,url,headRefName,stategh issue list --json number,title,state,url````gfj bootstrap` checks these same basics and prints the matching commands when something is missing.5 unmodified linesOnly these commands are routed for allowlisted Forgejo repositories:```shgh pr checksgh pr checkoutgh pr commentgh pr creategh pr diffgh pr listgh pr newgh pr statusgh pr viewgh issue creategh issue listgh issue newgh issue viewgh repo view```29 unmodified linesstatusCheckRollup````gh issue list --json ...` and `gh issue view --json ...` support the common GitHub CLI issue fields:```textassignees, author, body, closed, closedAt,closedByPullRequestsReferences, comments, createdAt, id, isPinned,labels, milestone, number, projectCards, projectItems, reactionGroups,state, stateReason, title, updatedAt, url````gh repo view --json ...` supports the fields GitHub-oriented tools commonly probe:```textarchivedAt, assignableUsers, codeOfConduct, contactLinks, createdAt,defaultBranchRef, deleteBranchOnMerge, description, diskUsage, forkCount,fundingLinks, hasDiscussionsEnabled, hasIssuesEnabled, hasProjectsEnabled,hasWikiEnabled, homepageUrl, id, isArchived, isBlankIssuesEnabled,isEmpty, isFork, isInOrganization, isMirror, isPrivate,isSecurityPolicyEnabled, isTemplate, isUserConfigurationRepository,issueTemplates, issues, labels, languages, latestRelease, licenseInfo,mentionableUsers, mergeCommitAllowed, milestones, mirrorUrl, name,nameWithOwner, openGraphImageUrl, owner, parent, primaryLanguage,projects, projectsV2, pullRequestTemplates, pullRequests, pushedAt,rebaseMergeAllowed, repositoryTopics, securityPolicyUrl,squashMergeAllowed, sshUrl, stargazerCount, templateRepository,updatedAt, url, usesCustomOpenGraphImage, viewerCanAdminister,viewerDefaultCommitEmail, viewerDefaultMergeMethod, viewerHasStarred,viewerPermission, viewerPossibleCommitEmails, viewerSubscription,visibility, watchers````gh pr status --json ...` follows the GitHub CLI status envelope:
84 unmodified lines85868784 unmodified linesreturn Nonevalue = completed.stdout.strip()return value or None84 unmodified lines85868788899091929394959697989910010110210384 unmodified linesreturn Nonevalue = completed.stdout.strip()return value or Nonedef git_run(args: list[str], *, cwd: str | None = None) -> tuple[int, str]:try:completed = subprocess.run(["git", *args],cwd=cwd,check=False,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,)except OSError as exc:return 127, str(exc)message = completed.stderr.strip() or completed.stdout.strip()return completed.returncode, message
53 unmodified lines54555657585960616219 unmodified lines82838485868788899010 unmodified lines10110210310410510610710810911011111253 unmodified linesdef get_pull(self, repo: RepoRef, number: int) -> dict[str, Any]:return self._request_json("GET", f"{repo.api_base_url}/pulls/{number}", None)def get_repo(self, repo: RepoRef) -> dict[str, Any]:return self._request_json("GET", repo.api_base_url, None)def list_pulls(self,repo: RepoRef,19 unmodified linesurl: str,payload: dict[str, Any] | None,) -> Any:data = None if payload is None else json.dumps(payload).encode("utf-8")headers = {"Accept": "application/json","User-Agent": "gh-forgejo-shim",}if data is not None:10 unmodified linesexcept urllib.error.URLError as exc:raise ForgejoError(f"Forgejo API request failed: {exc.reason}") from excif not response_data:return {}try:return json.loads(response_data.decode("utf-8"))except json.JSONDecodeError as exc:raise ForgejoError("Forgejo API returned invalid JSON") from excdef _quote(value: str) -> str:53 unmodified lines5455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914019 unmodified lines16016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119210 unmodified lines20320420520620720820953 unmodified linesdef get_pull(self, repo: RepoRef, number: int) -> dict[str, Any]:return self._request_json("GET", f"{repo.api_base_url}/pulls/{number}", None)def get_pull_diff(self, repo: RepoRef, number: int, *, patch: bool = False) -> str:diff_type = "patch" if patch else "diff"return self._request_text("GET", f"{repo.api_base_url}/pulls/{number}.{diff_type}", None)def list_pull_files(self, repo: RepoRef, number: int) -> list[dict[str, Any]]:files = self._request_json("GET", f"{repo.api_base_url}/pulls/{number}/files", None)if not isinstance(files, list):return []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)def list_issues(self,repo: RepoRef,*,state: str = "open",labels: tuple[str, ...] = (),query: str | None = None,milestone: str | None = None,author: str | None = None,assignee: str | None = None,mentioned: str | None = None,limit: int | None = None,) -> list[dict[str, Any]]:params: dict[str, object] = {"state": state, "type": "issues"}if labels:params["labels"] = ",".join(labels)if query:params["q"] = queryif milestone:params["milestones"] = milestoneif author and author != "@me":params["created_by"] = authorif assignee and assignee != "@me":params["assigned_by"] = assigneeif mentioned and mentioned != "@me":params["mentioned_by"] = mentionedif limit is not None:params["limit"] = limitquery_string = urllib.parse.urlencode(params)issues = self._request_json("GET", f"{repo.api_base_url}/issues?{query_string}", None)if not isinstance(issues, list):return []return [item for item in issues if isinstance(item, dict)]def get_issue(self, repo: RepoRef, number: int) -> dict[str, Any]:return self._request_json("GET", f"{repo.api_base_url}/issues/{number}", None)def list_labels(self, repo: RepoRef) -> list[dict[str, Any]]:labels = self._request_json("GET", f"{repo.api_base_url}/labels", None)if not isinstance(labels, list):return []return [item for item in labels if isinstance(item, dict)]def create_issue(self,repo: RepoRef,*,title: str,body: str = "",assignees: tuple[str, ...] = (),labels: tuple[int, ...] = (),milestone: int | None = None,) -> dict[str, Any]:payload: dict[str, Any] = {"title": title,"body": body,}if assignees:payload["assignees"] = list(assignees)if labels:payload["labels"] = list(labels)if milestone is not None:payload["milestone"] = milestonereturn self._request_json("POST", f"{repo.api_base_url}/issues", payload)def create_issue_comment(self, repo: RepoRef, number: int, *, body: str) -> dict[str, Any]:return self._request_json("POST", f"{repo.api_base_url}/issues/{number}/comments", {"body": body})def list_pulls(self,repo: RepoRef,19 unmodified linesurl: str,payload: dict[str, Any] | None,) -> Any:response_data = self._request(method, url, payload, accept="application/json")if not response_data:return {}try:return json.loads(response_data.decode("utf-8"))except json.JSONDecodeError as exc:raise ForgejoError("Forgejo API returned invalid JSON") from excdef _request_text(self,method: str,url: str,payload: dict[str, Any] | None,) -> str:return self._request(method, url, payload, accept="text/plain").decode("utf-8", errors="replace")def _request(self,method: str,url: str,payload: dict[str, Any] | None,*,accept: str,) -> bytes:data = None if payload is None else json.dumps(payload).encode("utf-8")headers = {"Accept": accept,"User-Agent": "gh-forgejo-shim",}if data is not None:10 unmodified linesexcept urllib.error.URLError as exc:raise ForgejoError(f"Forgejo API request failed: {exc.reason}") from excreturn response_datadef _quote(value: str) -> str:
22 unmodified lines2324252627282930313233343568 unmodified lines1041051061071081091101111121131141151161171181191201213 unmodified lines1251261271281291304 unmodified lines13513613722 unmodified lines)SUPPORTED_REPO_JSON_FIELDS = ("description","defaultBranchRef","isPrivate","name","nameWithOwner","owner","sshUrl","url",)68 unmodified linesowner_login = owner.get("login") or owner.get("username") or getattr(fallback, "owner")name = repo.get("name") or getattr(fallback, "repo")default_branch = repo.get("default_branch")return {"description": repo.get("description"),"defaultBranchRef": {"name": default_branch} if default_branch else None,"isPrivate": bool(repo.get("private", False)),"name": name,"nameWithOwner": repo.get("full_name") or f"{owner_login}/{name}","owner": {"login": owner_login,"type": owner.get("type"),},"sshUrl": repo.get("ssh_url"),"url": repo.get("html_url") or getattr(fallback, "web_base_url"),}3 unmodified linesreturn {field: data.get(field) for field in fields if field in SUPPORTED_REPO_JSON_FIELDS}def status_for_current_branch(pull: dict[str, Any] | None,fields: tuple[str, ...],4 unmodified lines"createdBy": [],"needsReview": [],}22 unmodified lines232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711868 unmodified lines1871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732743 unmodified lines2782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183194 unmodified lines32432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140222 unmodified lines)SUPPORTED_REPO_JSON_FIELDS = ("archivedAt","assignableUsers","codeOfConduct","contactLinks","createdAt","defaultBranchRef","deleteBranchOnMerge","description","diskUsage","forkCount","fundingLinks","hasDiscussionsEnabled","hasIssuesEnabled","hasProjectsEnabled","hasWikiEnabled","homepageUrl","id","isArchived","isBlankIssuesEnabled","isEmpty","isFork","isInOrganization","isMirror","isPrivate","isSecurityPolicyEnabled","isTemplate","isUserConfigurationRepository","issueTemplates","issues","labels","languages","latestRelease","licenseInfo","mentionableUsers","mergeCommitAllowed","milestones","mirrorUrl","name","nameWithOwner","openGraphImageUrl","owner","parent","primaryLanguage","projects","projectsV2","pullRequestTemplates","pullRequests","pushedAt","rebaseMergeAllowed","repositoryTopics","securityPolicyUrl","squashMergeAllowed","sshUrl","stargazerCount","templateRepository","updatedAt","url","usesCustomOpenGraphImage","viewerCanAdminister","viewerDefaultCommitEmail","viewerDefaultMergeMethod","viewerHasStarred","viewerPermission","viewerPossibleCommitEmails","viewerSubscription","visibility","watchers",)SUPPORTED_ISSUE_JSON_FIELDS = ("assignees","author","body","closed","closedAt","closedByPullRequestsReferences","comments","createdAt","id","isPinned","labels","milestone","number","projectCards","projectItems","reactionGroups","state","stateReason","title","updatedAt","url",)68 unmodified linesowner_login = owner.get("login") or owner.get("username") or getattr(fallback, "owner")name = repo.get("name") or getattr(fallback, "repo")default_branch = repo.get("default_branch")language = repo.get("language")topics = repo.get("topics") if isinstance(repo.get("topics"), list) else []permissions = repo.get("permissions") if isinstance(repo.get("permissions"), dict) else {}is_private = bool(repo.get("private", False))is_internal = bool(repo.get("internal", False))return {"archivedAt": repo.get("archived_at"),"assignableUsers": [],"codeOfConduct": None,"contactLinks": [],"createdAt": repo.get("created_at"),"description": repo.get("description"),"defaultBranchRef": {"name": default_branch} if default_branch else None,"deleteBranchOnMerge": bool(repo.get("default_delete_branch_after_merge", False)),"diskUsage": _normalize_count(repo.get("size")),"forkCount": _normalize_count(repo.get("forks_count")),"fundingLinks": [],"hasDiscussionsEnabled": False,"hasIssuesEnabled": bool(repo.get("has_issues", False)),"hasProjectsEnabled": bool(repo.get("has_projects", False)),"hasWikiEnabled": bool(repo.get("has_wiki", False)),"homepageUrl": repo.get("website") or None,"id": repo.get("id"),"isArchived": bool(repo.get("archived", False)),"isBlankIssuesEnabled": bool(repo.get("has_issues", False)),"isEmpty": bool(repo.get("empty", False)),"isFork": bool(repo.get("fork", False)),"isInOrganization": _is_organization(owner),"isMirror": bool(repo.get("mirror", False)),"isPrivate": is_private,"isSecurityPolicyEnabled": False,"isTemplate": bool(repo.get("template", False)),"isUserConfigurationRepository": False,"issueTemplates": [],"issues": _connection(_normalize_count(repo.get("open_issues_count"))),"labels": _connection(0),"languages": {"nodes": [{"name": language}] if isinstance(language, str) and language else [],"totalSize": 0,},"latestRelease": None,"licenseInfo": None,"mentionableUsers": [],"mergeCommitAllowed": bool(repo.get("allow_merge_commits", False)),"milestones": _connection(0),"mirrorUrl": repo.get("original_url") or None,"name": name,"nameWithOwner": repo.get("full_name") or f"{owner_login}/{name}","openGraphImageUrl": repo.get("avatar_url"),"owner": {"login": owner_login,"type": owner.get("type"),},"parent": _normalize_parent(repo.get("parent")),"primaryLanguage": {"name": language} if isinstance(language, str) and language else None,"projects": _connection(0),"projectsV2": _connection(0),"pullRequestTemplates": [],"pullRequests": _connection(_normalize_count(repo.get("open_pr_counter"))),"pushedAt": repo.get("updated_at"),"rebaseMergeAllowed": bool(repo.get("allow_rebase", False)),"repositoryTopics": {"nodes": [{"topic": {"name": topic}} for topic in topics if isinstance(topic, str)],"totalCount": len([topic for topic in topics if isinstance(topic, str)]),},"securityPolicyUrl": None,"squashMergeAllowed": bool(repo.get("allow_squash_merge", False)),"sshUrl": repo.get("ssh_url"),"stargazerCount": _normalize_count(repo.get("stars_count")),"templateRepository": None,"updatedAt": repo.get("updated_at"),"url": repo.get("html_url") or getattr(fallback, "web_base_url"),"usesCustomOpenGraphImage": False,"viewerCanAdminister": bool(permissions.get("admin", False)),"viewerDefaultCommitEmail": None,"viewerDefaultMergeMethod": repo.get("default_merge_style"),"viewerHasStarred": False,"viewerPermission": _viewer_permission(permissions),"viewerPossibleCommitEmails": [],"viewerSubscription": None,"visibility": _visibility(is_private=is_private, is_internal=is_internal),"watchers": _connection(_normalize_count(repo.get("watchers_count"))),}3 unmodified linesreturn {field: data.get(field) for field in fields if field in SUPPORTED_REPO_JSON_FIELDS}def normalize_issue(issue: dict[str, Any]) -> dict[str, Any]:user = issue.get("user") if isinstance(issue.get("user"), dict) else {}milestone = issue.get("milestone") if isinstance(issue.get("milestone"), dict) else Nonestate = str(issue.get("state") or "").upper() or Noneclosed = state == "CLOSED"return {"assignees": [_normalize_user(item) for item in _list_of_dicts(issue.get("assignees"))],"author": _normalize_user(user),"body": issue.get("body") or "","closed": closed,"closedAt": issue.get("closed_at"),"closedByPullRequestsReferences": [],"comments": _normalize_count(issue.get("comments")),"createdAt": issue.get("created_at"),"id": issue.get("id"),"isPinned": _normalize_count(issue.get("pin_order")) > 0,"labels": [_normalize_label(item) for item in _list_of_dicts(issue.get("labels"))],"milestone": _normalize_milestone(milestone),"number": issue.get("number") or issue.get("id"),"projectCards": [],"projectItems": [],"reactionGroups": [],"state": state,"stateReason": None,"title": issue.get("title"),"updatedAt": issue.get("updated_at"),"url": issue.get("html_url") or issue.get("url"),}def filter_issue_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_ISSUE_JSON_FIELDS}def status_for_current_branch(pull: dict[str, Any] | None,fields: tuple[str, ...],4 unmodified lines"createdBy": [],"needsReview": [],}def _connection(total_count: int, nodes: list[dict[str, Any]] | None = None) -> dict[str, Any]:return {"nodes": [] if nodes is None else nodes,"totalCount": total_count,}def _is_organization(owner: dict[str, Any]) -> bool:owner_type = str(owner.get("type") or "").lower()return owner_type in {"organization", "org"}def _normalize_parent(parent: Any) -> dict[str, Any] | None:if not isinstance(parent, dict):return Noneowner = parent.get("owner") if isinstance(parent.get("owner"), dict) else {}name = parent.get("name")owner_login = owner.get("login") or owner.get("username")name_with_owner = parent.get("full_name")if not name_with_owner and owner_login and name:name_with_owner = f"{owner_login}/{name}"return {"name": name,"nameWithOwner": name_with_owner,"url": parent.get("html_url") or parent.get("url"),}def _viewer_permission(permissions: dict[str, Any]) -> str | None:if permissions.get("admin"):return "ADMIN"if permissions.get("push"):return "WRITE"if permissions.get("pull"):return "READ"return Nonedef _visibility(*, is_private: bool, is_internal: bool) -> str:if is_internal:return "INTERNAL"return "PRIVATE" if is_private else "PUBLIC"def _normalize_user(user: dict[str, Any]) -> dict[str, Any]:return {"login": user.get("login") or user.get("username"),"name": user.get("full_name") or user.get("name"),}def _normalize_label(label: dict[str, Any]) -> dict[str, Any]:return {"color": label.get("color"),"description": label.get("description"),"id": label.get("id"),"name": label.get("name"),}def _normalize_milestone(milestone: dict[str, Any] | None) -> dict[str, Any] | None:if milestone is None:return Nonereturn {"number": milestone.get("id") or milestone.get("number"),"title": milestone.get("title"),"state": str(milestone.get("state") or "").upper() or None,}def _list_of_dicts(value: Any) -> list[dict[str, Any]]:if not isinstance(value, list):return []return [item for item in value if isinstance(item, dict)]
25 unmodified lines26272829303127 unmodified lines59606162636443 unmodified lines10810911011111211325 unmodified linesif parsed.scheme in {"http", "https", "ssh", "git"} and parsed.netloc:host = parsed.hostname or parsed.netloc.split("@")[-1]parts = _path_parts(parsed.path)if len(parts) >= 2:return RepoRef(normalize_host(host), parts[-2], _strip_git(parts[-1]))return None27 unmodified linesif repo:return Detection(repo, "-R/--repo")gh_repo = values.get("GH_REPO")if gh_repo:repo = parse_repo_spec(gh_repo, default_host=values.get("GH_HOST"))43 unmodified linesreturn Nonedef current_branch(*, cwd: str | None = None) -> str | None:return git_output(["branch", "--show-current"], cwd=cwd)25 unmodified lines262728293031323327 unmodified lines61626364656667686970717243 unmodified lines11611711811912012112212312412512612712825 unmodified linesif parsed.scheme in {"http", "https", "ssh", "git"} and parsed.netloc:host = parsed.hostname or parsed.netloc.split("@")[-1]parts = _path_parts(parsed.path)if len(parts) >= 4 and parts[2] in {"issue", "issues", "pull", "pulls"}:return RepoRef(normalize_host(host), parts[0], _strip_git(parts[1]))if len(parts) >= 2:return RepoRef(normalize_host(host), parts[-2], _strip_git(parts[-1]))return None27 unmodified linesif repo:return Detection(repo, "-R/--repo")url_repo = extract_repo_url_arg(argv)if url_repo:repo = parse_repo_spec(url_repo, default_host=values.get("GH_HOST"))if repo:return Detection(repo, "command URL")gh_repo = values.get("GH_REPO")if gh_repo:repo = parse_repo_spec(gh_repo, default_host=values.get("GH_HOST"))43 unmodified linesreturn Nonedef extract_repo_url_arg(argv: list[str]) -> str | None:for arg in argv:if urllib.parse.urlparse(arg).scheme in {"http", "https", "ssh", "git"}:return argreturn Nonedef current_branch(*, cwd: str | None = None) -> str | None:return git_output(["branch", "--show-current"], cwd=cwd)
12345678910111213141516171819207 unmodified lines2829303132333412 unmodified lines47484950515273 unmodified lines12612712812913013113 unmodified lines14514614714814915020 unmodified lines17117217317417517689 unmodified lines26626726826927027184 unmodified lines35635735835936036134 unmodified lines396397398399400401124 unmodified lines5265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615624 unmodified lines5675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246251 unmodified line6276286296306316326336346356366376386396406416426436446456466476486496506512 unmodified lines6546556566576586599 unmodified lines66967067167267367462 unmodified lines737738739from __future__ import annotationsimport jsonimport osimport sysimport webbrowserfrom dataclasses import dataclassfrom typing import Callable, Mapping, TextIOfrom .auth import discover_fj_tokenfrom .config import Config, load_configfrom .create import CreateParseError, parse_create_argsfrom .external import find_program, run_programfrom .forgejo import ForgejoClient, ForgejoError, RepoReffrom .normalize import (filter_fields,filter_repo_fields,normalize_pull,normalize_repo,status_for_current_branch,7 unmodified linesparse_repo_spec,)SUPPORTED_PR_COMMANDS = {"checks", "create", "list", "new", "status", "view"}SUPPORTED_REPO_COMMANDS = {"view"}12 unmodified linesreturn Falseif argv[0] == "pr":return argv[1] in SUPPORTED_PR_COMMANDSif argv[0] == "repo":return argv[1] in SUPPORTED_REPO_COMMANDSreturn False73 unmodified lines) -> int:if argv[0] == "repo":return run_forgejo_repo(argv, repo, client, stdout=stdout, stderr=stderr)return run_forgejo_pr(argv, repo, client, cwd=cwd, stdout=stdout, stderr=stderr, stdin=stdin)13 unmodified linesreturn _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":20 unmodified linesreturn 1def _run_create(argv: list[str],repo: RepoRef,89 unmodified linesreturn 0def _run_view(argv: list[str],repo: RepoRef,84 unmodified linesreturn 0@dataclass(frozen=True)class ViewStatusArgs:json_fields: tuple[str, ...] = ()34 unmodified linesnumber: int | None = Nonedef _parse_list_args(argv: list[str]) -> ListArgs:json_fields: tuple[str, ...] = ()repo = None124 unmodified lineselif arg.startswith("--jq=") or arg.startswith("-q="):jq = arg.split("=", 1)[1]index += 1elif arg in {"--watch", "--fail-fast", "--required"}:index += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo PR checks flag: {arg}")else:if arg.isdigit():number = int(arg)index += 1return ChecksArgs(json_fields=json_fields, repo=repo, jq=jq, number=number)def _parse_view_status_args(argv: list[str]) -> ViewStatusArgs:json_fields: tuple[str, ...] = ()repo = Noneweb = Falsejq = Nonetemplate = Nonenumber = Nonebranch = Noneindex = 0while index < len(argv):arg = argv[index]if arg in {"--json"}:if index + 1 >= len(argv):raise ValueError("missing value for --json")json_fields = _split_fields(argv[index + 1])index += 2elif arg.startswith("--json="):json_fields = _split_fields(arg.split("=", 1)[1])index += 1elif arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]4 unmodified lineselif arg in {"--web", "-w"}:web = Trueindex += 1elif arg in {"--jq", "-q"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")jq = argv[index + 1]index += 2elif arg.startswith("--jq=") or arg.startswith("-q="):jq = arg.split("=", 1)[1]index += 1elif arg in {"--template", "-t"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")template = argv[index + 1]index += 2elif arg.startswith("--template=") or arg.startswith("-t="):template = arg.split("=", 1)[1]index += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo PR flag: {arg}")else:if arg.isdigit():number = int(arg)else:branch = argindex += 1return ViewStatusArgs(json_fields=json_fields,repo=repo,web=web,jq=jq,template=template,number=number,branch=branch,)def _parse_repo_view_args(argv: list[str]) -> RepoViewArgs:json_fields: tuple[str, ...] = ()repo = Noneweb = Falsejq = Nonetemplate = Noneindex = 0while index < len(argv):arg = argv[index]if arg in {"--json"}:if index + 1 >= len(argv):raise ValueError("missing value for --json")json_fields = _split_fields(argv[index + 1])index += 2elif arg.startswith("--json="):json_fields = _split_fields(arg.split("=", 1)[1])index += 1elif arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]1 unmodified lineelif arg.startswith("--repo="):repo = arg.split("=", 1)[1]index += 1elif arg in {"--web", "-w"}:web = Trueindex += 1elif arg in {"--jq", "-q"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")jq = argv[index + 1]index += 2elif arg.startswith("--jq=") or arg.startswith("-q="):jq = arg.split("=", 1)[1]index += 1elif arg in {"--template", "-t"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")template = argv[index + 1]index += 2elif arg.startswith("--template=") or arg.startswith("-t="):template = arg.split("=", 1)[1]index += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo repo view flag: {arg}")else:2 unmodified linesreturn RepoViewArgs(json_fields=json_fields, repo=repo, web=web, jq=jq, template=template)def _format_pull_text(data: dict[str, object]) -> str:number = data.get("number")title = data.get("title") or ""9 unmodified linesreturn f"{number}\t{title}\t{branch}".rstrip()def _print_json_or_jq(data: dict[str, object], jq: str | None, stdout: TextIO) -> None:if jq:value = _apply_simple_jq(data, jq)62 unmodified linesif normalized not in {"open", "closed", "merged", "all"}:raise ValueError("--state must be one of: open, closed, merged, all")return normalized1234567891011121314151617181920212223247 unmodified lines323334353637383912 unmodified lines525354555657585973 unmodified lines13313413513613713813914013 unmodified lines15415515615715815916016116216316416520 unmodified lines18618718818919019119219319419519619719819920020120220320420520620720820921021121289 unmodified lines30230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744884 unmodified lines53353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563634 unmodified lines671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746124 unmodified lines8718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079084 unmodified lines9139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559561 unmodified line95895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511562 unmodified lines115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314549 unmodified lines146414651466146714681469147014711472147314741475147614771478147914801481148214831484148562 unmodified lines1548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662from __future__ import annotationsimport fnmatchimport jsonimport osimport sysimport urllib.parseimport webbrowserfrom dataclasses import dataclassfrom typing import Callable, Mapping, TextIOfrom .auth import discover_fj_tokenfrom .config import Config, load_configfrom .create import CreateParseError, body_from_file, parse_create_argsfrom .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,7 unmodified linesparse_repo_spec,)SUPPORTED_PR_COMMANDS = {"checks", "checkout", "co", "comment", "create", "diff", "list", "new", "status", "view"}SUPPORTED_ISSUE_COMMANDS = {"create", "list", "ls", "new", "view"}SUPPORTED_REPO_COMMANDS = {"view"}12 unmodified linesreturn Falseif argv[0] == "pr":return argv[1] in SUPPORTED_PR_COMMANDSif argv[0] == "issue":return argv[1] in SUPPORTED_ISSUE_COMMANDSif argv[0] == "repo":return argv[1] in SUPPORTED_REPO_COMMANDSreturn False73 unmodified lines) -> int:if argv[0] == "repo":return run_forgejo_repo(argv, repo, client, stdout=stdout, stderr=stderr)if argv[0] == "issue":return run_forgejo_issue(argv, repo, client, stdout=stdout, stderr=stderr, stdin=stdin)return run_forgejo_pr(argv, repo, client, cwd=cwd, stdout=stdout, stderr=stderr, stdin=stdin)13 unmodified linesreturn _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":return _run_pr_comment(rest, repo, client, cwd=cwd, stdout=stdout, stdin=stdin)if command == "diff":return _run_diff(rest, repo, client, cwd=cwd, stdout=stdout)if command == "list":return _run_list(rest, repo, client, stdout=stdout)if command == "view":20 unmodified linesreturn 1def run_forgejo_issue(argv: list[str],repo: RepoRef,client: ForgejoClient,*,stdout: TextIO,stderr: TextIO,stdin: TextIO | None = None,) -> int:command = argv[1]rest = argv[2:]if command in {"create", "new"}:return _run_issue_create(rest, repo, client, stdout=stdout, stdin=stdin)if command in {"list", "ls"}:return _run_issue_list(rest, repo, client, stdout=stdout)if command == "view":return _run_issue_view(rest, repo, client, stdout=stdout)print(f"gh-forgejo-shim: unsupported Forgejo issue command: {command}", file=stderr)return 1def _run_create(argv: list[str],repo: RepoRef,89 unmodified linesreturn 0def _run_diff(argv: list[str],repo: RepoRef,client: ForgejoClient,*,cwd: str | None,stdout: TextIO,) -> int:parsed = _parse_diff_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=parsed.branch, cwd=cwd)if pull is None:return 0number = _pull_number(pull)if number is None:return 0if parsed.web:url = str(normalize_pull(pull).get("url") or f"{target_repo.web_base_url}/pulls/{number}") + "/files"webbrowser.open(url)print(url, file=stdout)return 0if parsed.name_only:for name in _filtered_file_names(client.list_pull_files(target_repo, number), parsed.excludes):print(name, file=stdout)return 0text = client.get_pull_diff(target_repo, number, patch=parsed.patch)print(text, end="" if text.endswith("\n") else "\n", file=stdout)return 0def _run_pr_comment(argv: list[str],repo: RepoRef,client: ForgejoClient,*,cwd: str | None,stdout: TextIO,stdin: TextIO | None,) -> int:parsed = _parse_comment_args(argv, stdin=stdin)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=parsed.branch, cwd=cwd)if pull is None:raise ValueError("could not find pull request to comment on")number = _pull_number(pull)if number is None:raise ValueError("could not determine pull request number")if parsed.web:url = str(normalize_pull(pull).get("url") or f"{target_repo.web_base_url}/pulls/{number}") + "#new-comment"webbrowser.open(url)print(url, file=stdout)return 0if parsed.body is None:raise ValueError("Forgejo PR comment requires --body or --body-file")client.create_issue_comment(target_repo, number, body=parsed.body)return 0def _run_checkout(argv: list[str],repo: RepoRef,client: ForgejoClient,*,cwd: str | None,stdout: TextIO,stderr: TextIO,) -> int:parsed = _parse_checkout_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=parsed.branch, cwd=cwd)if pull is None:raise ValueError("could not find pull request to check out")number = _pull_number(pull)head_ref = _pull_head_ref(pull)fetch_refs = []if head_ref:fetch_refs.append(head_ref)if number is not None:fetch_refs.append(f"refs/pull/{number}/head")fetched = Falsefetch_error = ""for fetch_ref in dict.fromkeys(fetch_refs):code, message = git_run(["fetch", "origin", fetch_ref], cwd=cwd)if code == 0:fetched = Truebreakfetch_error = messageif not fetched:print(fetch_error or "git fetch failed", file=stderr)return 1if parsed.detach:code, message = git_run(["checkout", "--detach", "FETCH_HEAD"], cwd=cwd)if code != 0:print(message or "git checkout failed", file=stderr)return codeprint("checked out FETCH_HEAD", file=stdout)return 0branch = parsed.local_branch or head_ref or (f"pull-{number}" if number is not None else None)if branch is None:raise ValueError("could not determine local branch name")if parsed.force:checkout_args = ["checkout", "-B", branch, "FETCH_HEAD"]else:exists, _ = git_run(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=cwd)checkout_args = ["checkout", branch] if exists == 0 else ["checkout", "-b", branch, "FETCH_HEAD"]code, message = git_run(checkout_args, cwd=cwd)if code != 0:print(message or "git checkout failed", file=stderr)return codeif parsed.recurse_submodules:code, message = git_run(["submodule", "update", "--init", "--recursive"], cwd=cwd)if code != 0:print(message or "git submodule update failed", file=stderr)return codeprint(f"checked out {branch}", file=stdout)return 0def _run_view(argv: list[str],repo: RepoRef,84 unmodified linesreturn 0def _run_issue_list(argv: list[str],repo: RepoRef,client: ForgejoClient,*,stdout: TextIO,) -> int:parsed = _parse_issue_list_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")if parsed.web:url = f"{target_repo.web_base_url}/issues"webbrowser.open(url)print(url, file=stdout)return 0issues = client.list_issues(target_repo,state=parsed.state,labels=parsed.labels,query=parsed.query,milestone=parsed.milestone,author=parsed.author,assignee=parsed.assignee,mentioned=parsed.mentioned,limit=parsed.limit,)if parsed.limit is not None:issues = issues[: parsed.limit]normalized = [filter_issue_fields(normalize_issue(issue), parsed.json_fields) for issue in issues]if parsed.json_fields:_print_json_or_jq_list(normalized, parsed.jq, stdout)else:for issue in normalized:print(_format_issue_list_item(issue), file=stdout)return 0def _run_issue_view(argv: list[str],repo: RepoRef,client: ForgejoClient,*,stdout: TextIO,) -> int:parsed = _parse_issue_view_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")if parsed.number is None:raise ValueError("Forgejo issue view requires an issue number or URL")issue = client.get_issue(target_repo, parsed.number)normalized = normalize_issue(issue)if parsed.web:url = str(normalized.get("url") or f"{target_repo.web_base_url}/issues/{parsed.number}")webbrowser.open(url)print(url, file=stdout)return 0if parsed.json_fields:_print_json_or_jq(filter_issue_fields(normalized, parsed.json_fields), parsed.jq, stdout)else:print(_format_issue_text(normalized), file=stdout)return 0def _run_issue_create(argv: list[str],repo: RepoRef,client: ForgejoClient,*,stdout: TextIO,stdin: TextIO | None,) -> int:parsed = _parse_issue_create_args(argv, stdin=stdin)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")if parsed.web:url = f"{target_repo.web_base_url}/issues/new"webbrowser.open(url)print(url, file=stdout)return 0if not parsed.title:raise ValueError("Forgejo issue create requires --title")issue = client.create_issue(target_repo,title=parsed.title,body=parsed.body or "",assignees=parsed.assignees,labels=_resolve_label_ids(client, target_repo, parsed.labels),milestone=parsed.milestone,)normalized = normalize_issue(issue)print(normalized.get("url") or issue.get("html_url") or issue.get("url") or "", file=stdout)return 0@dataclass(frozen=True)class ViewStatusArgs:json_fields: tuple[str, ...] = ()34 unmodified linesnumber: int | None = None@dataclass(frozen=True)class DiffArgs:repo: str | None = Nonenumber: int | None = Nonebranch: str | None = Noneweb: bool = Falsename_only: bool = Falsepatch: bool = Falseexcludes: tuple[str, ...] = ()@dataclass(frozen=True)class CommentArgs:repo: str | None = Nonenumber: int | None = Nonebranch: str | None = Nonebody: str | None = Noneweb: bool = False@dataclass(frozen=True)class CheckoutArgs:repo: str | None = Nonenumber: int | None = Nonebranch: str | None = Nonelocal_branch: str | None = Nonedetach: bool = Falseforce: bool = Falserecurse_submodules: bool = False@dataclass(frozen=True)class IssueListArgs:json_fields: tuple[str, ...] = ()repo: str | None = Nonejq: str | None = Nonetemplate: str | None = Nonestate: str = "open"limit: int | None = 30labels: tuple[str, ...] = ()query: str | None = Noneauthor: str | None = Noneassignee: str | None = Nonementioned: str | None = Nonemilestone: str | None = Noneweb: bool = False@dataclass(frozen=True)class IssueViewArgs:json_fields: tuple[str, ...] = ()repo: str | None = Nonejq: str | None = Nonetemplate: str | None = Nonenumber: int | None = Noneweb: bool = Falsecomments: bool = False@dataclass(frozen=True)class IssueCreateArgs:title: str | None = Nonebody: str | None = Nonerepo: str | None = Noneweb: bool = Falseassignees: tuple[str, ...] = ()labels: tuple[str, ...] = ()milestone: int | None = Nonedef _parse_list_args(argv: list[str]) -> ListArgs:json_fields: tuple[str, ...] = ()repo = None124 unmodified lineselif arg.startswith("--jq=") or arg.startswith("-q="):jq = arg.split("=", 1)[1]index += 1elif arg in {"--template", "-t", "--interval", "-i"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")index += 2elif arg.startswith(("--template=", "-t=", "--interval=", "-i=")):index += 1elif arg in {"--web", "-w"}:index += 1elif arg in {"--watch", "--fail-fast", "--required"}:index += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo PR checks flag: {arg}")else:parsed_number, _ = _parse_number_or_branch(arg, kind="pull")if parsed_number is not None:number = parsed_numberindex += 1return ChecksArgs(json_fields=json_fields, repo=repo, jq=jq, number=number)def _parse_diff_args(argv: list[str]) -> DiffArgs:repo = Nonenumber = Nonebranch = Noneweb = Falsename_only = Falsepatch = Falseexcludes: list[str] = []index = 0while index < len(argv):arg = argv[index]if arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]4 unmodified lineselif arg in {"--web", "-w"}:web = Trueindex += 1elif arg == "--name-only":name_only = Trueindex += 1elif arg == "--patch":patch = Trueindex += 1elif arg == "--color":if index + 1 >= len(argv):raise ValueError("missing value for --color")index += 2elif arg.startswith("--color="):index += 1elif arg in {"--exclude", "-e"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")excludes.append(argv[index + 1])index += 2elif arg.startswith("--exclude=") or arg.startswith("-e="):excludes.append(arg.split("=", 1)[1])index += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo PR diff flag: {arg}")else:number, branch = _parse_number_or_branch(arg, kind="pull")index += 1return DiffArgs(repo=repo, number=number, branch=branch, web=web, name_only=name_only, patch=patch, excludes=tuple(excludes))def _parse_comment_args(argv: list[str], *, stdin: TextIO | None = None) -> CommentArgs:repo = Nonenumber = Nonebranch = Nonebody = Noneweb = Falseindex = 0while index < len(argv):arg = argv[index]if arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]1 unmodified lineelif arg.startswith("--repo="):repo = arg.split("=", 1)[1]index += 1elif arg in {"--body", "-b"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")body = argv[index + 1]index += 2elif arg.startswith("--body=") or arg.startswith("-b="):body = arg.split("=", 1)[1]index += 1elif arg in {"--body-file", "-F"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")body = body_from_file(argv[index + 1], stdin=stdin)index += 2elif arg.startswith("--body-file=") or arg.startswith("-F="):body = body_from_file(arg.split("=", 1)[1], stdin=stdin)index += 1elif arg in {"--web", "-w"}:web = Trueindex += 1elif arg in {"--create-if-none", "--delete-last", "--edit-last", "--editor", "-e", "--yes"}:raise ValueError(f"unsupported Forgejo PR comment flag: {arg}")elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo PR comment flag: {arg}")else:number, branch = _parse_number_or_branch(arg, kind="pull")index += 1return CommentArgs(repo=repo, number=number, branch=branch, body=body, web=web)def _parse_checkout_args(argv: list[str]) -> CheckoutArgs:repo = Nonenumber = Nonebranch = Nonelocal_branch = Nonedetach = Falseforce = Falserecurse_submodules = Falseindex = 0while index < len(argv):arg = argv[index]if arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]index += 2elif arg.startswith("--repo="):repo = arg.split("=", 1)[1]index += 1elif arg in {"--branch", "-b"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")local_branch = argv[index + 1]index += 2elif arg.startswith("--branch=") or arg.startswith("-b="):local_branch = arg.split("=", 1)[1]index += 1elif arg == "--detach":detach = Trueindex += 1elif arg in {"--force", "-f"}:force = Trueindex += 1elif arg == "--recurse-submodules":recurse_submodules = Trueindex += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo PR checkout flag: {arg}")else:number, branch = _parse_number_or_branch(arg, kind="pull")index += 1return CheckoutArgs(repo=repo,number=number,branch=branch,local_branch=local_branch,detach=detach,force=force,recurse_submodules=recurse_submodules,)def _parse_view_status_args(argv: list[str]) -> ViewStatusArgs:json_fields: tuple[str, ...] = ()repo = Noneweb = Falsejq = Nonetemplate = Nonenumber = Nonebranch = Noneindex = 0while index < len(argv):arg = argv[index]if arg in {"--json"}:if index + 1 >= len(argv):raise ValueError("missing value for --json")json_fields = _split_fields(argv[index + 1])index += 2elif arg.startswith("--json="):json_fields = _split_fields(arg.split("=", 1)[1])index += 1elif arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]index += 2elif arg.startswith("--repo="):repo = arg.split("=", 1)[1]index += 1elif arg in {"--web", "-w"}:web = Trueindex += 1elif arg in {"--jq", "-q"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")jq = argv[index + 1]index += 2elif arg.startswith("--jq=") or arg.startswith("-q="):jq = arg.split("=", 1)[1]index += 1elif arg in {"--template", "-t"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")template = argv[index + 1]index += 2elif arg.startswith("--template=") or arg.startswith("-t="):template = arg.split("=", 1)[1]index += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo PR flag: {arg}")else:number, branch = _parse_number_or_branch(arg, kind="pull")index += 1return ViewStatusArgs(json_fields=json_fields,repo=repo,web=web,jq=jq,template=template,number=number,branch=branch,)def _parse_repo_view_args(argv: list[str]) -> RepoViewArgs:json_fields: tuple[str, ...] = ()repo = Noneweb = Falsejq = Nonetemplate = Noneindex = 0while index < len(argv):arg = argv[index]if arg in {"--json"}:if index + 1 >= len(argv):raise ValueError("missing value for --json")json_fields = _split_fields(argv[index + 1])index += 2elif arg.startswith("--json="):json_fields = _split_fields(arg.split("=", 1)[1])index += 1elif arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]index += 2elif arg.startswith("--repo="):repo = arg.split("=", 1)[1]index += 1elif arg in {"--web", "-w"}:web = Trueindex += 1elif arg in {"--jq", "-q"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")jq = argv[index + 1]index += 2elif arg.startswith("--jq=") or arg.startswith("-q="):jq = arg.split("=", 1)[1]index += 1elif arg in {"--template", "-t"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")template = argv[index + 1]index += 2elif arg.startswith("--template=") or arg.startswith("-t="):template = arg.split("=", 1)[1]index += 1elif arg in {"--branch", "-b"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")index += 2elif arg.startswith("--branch=") or arg.startswith("-b="):index += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo repo view flag: {arg}")else:2 unmodified linesreturn RepoViewArgs(json_fields=json_fields, repo=repo, web=web, jq=jq, template=template)def _parse_issue_list_args(argv: list[str]) -> IssueListArgs:json_fields: tuple[str, ...] = ()repo = Nonejq = Nonetemplate = Nonestate = "open"limit: int | None = 30labels: list[str] = []query = Noneauthor = Noneassignee = Nonementioned = Nonemilestone = Noneweb = Falseindex = 0while index < len(argv):arg = argv[index]if arg in {"--json"}:if index + 1 >= len(argv):raise ValueError("missing value for --json")json_fields = _split_fields(argv[index + 1])index += 2elif arg.startswith("--json="):json_fields = _split_fields(arg.split("=", 1)[1])index += 1elif arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]index += 2elif arg.startswith("--repo="):repo = arg.split("=", 1)[1]index += 1elif arg in {"--jq", "-q"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")jq = argv[index + 1]index += 2elif arg.startswith("--jq=") or arg.startswith("-q="):jq = arg.split("=", 1)[1]index += 1elif arg in {"--template", "-t"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")template = argv[index + 1]index += 2elif arg.startswith("--template=") or arg.startswith("-t="):template = arg.split("=", 1)[1]index += 1elif arg in {"--state", "-s"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")state = _normalize_issue_state(argv[index + 1])index += 2elif arg.startswith("--state=") or arg.startswith("-s="):state = _normalize_issue_state(arg.split("=", 1)[1])index += 1elif arg in {"--limit", "-L"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")limit = _parse_limit(argv[index + 1])index += 2elif arg.startswith("--limit=") or arg.startswith("-L="):limit = _parse_limit(arg.split("=", 1)[1])index += 1elif arg in {"--label", "-l"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")labels.extend(_split_label_values(argv[index + 1]))index += 2elif arg.startswith("--label=") or arg.startswith("-l="):labels.extend(_split_label_values(arg.split("=", 1)[1]))index += 1elif arg in {"--search", "-S"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")query = argv[index + 1]index += 2elif arg.startswith("--search=") or arg.startswith("-S="):query = arg.split("=", 1)[1]index += 1elif arg in {"--author", "-A"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")author = argv[index + 1]index += 2elif arg.startswith("--author=") or arg.startswith("-A="):author = arg.split("=", 1)[1]index += 1elif arg in {"--assignee", "-a"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")assignee = argv[index + 1]index += 2elif arg.startswith("--assignee=") or arg.startswith("-a="):assignee = arg.split("=", 1)[1]index += 1elif arg == "--mention":if index + 1 >= len(argv):raise ValueError("missing value for --mention")mentioned = argv[index + 1]index += 2elif arg.startswith("--mention="):mentioned = arg.split("=", 1)[1]index += 1elif arg in {"--milestone", "-m"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")milestone = argv[index + 1]index += 2elif arg.startswith("--milestone=") or arg.startswith("-m="):milestone = arg.split("=", 1)[1]index += 1elif arg in {"--web", "-w"}:web = Trueindex += 1elif arg == "--app":if index + 1 >= len(argv):raise ValueError("missing value for --app")index += 2elif arg.startswith("--app="):index += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo issue list flag: {arg}")else:index += 1return IssueListArgs(json_fields=json_fields,repo=repo,jq=jq,template=template,state=state,limit=limit,labels=tuple(labels),query=query,author=author,assignee=assignee,mentioned=mentioned,milestone=milestone,web=web,)def _parse_issue_view_args(argv: list[str]) -> IssueViewArgs:json_fields: tuple[str, ...] = ()repo = Nonejq = Nonetemplate = Nonenumber = Noneweb = Falsecomments = Falseindex = 0while index < len(argv):arg = argv[index]if arg in {"--json"}:if index + 1 >= len(argv):raise ValueError("missing value for --json")json_fields = _split_fields(argv[index + 1])index += 2elif arg.startswith("--json="):json_fields = _split_fields(arg.split("=", 1)[1])index += 1elif arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]index += 2elif arg.startswith("--repo="):repo = arg.split("=", 1)[1]index += 1elif arg in {"--jq", "-q"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")jq = argv[index + 1]index += 2elif arg.startswith("--jq=") or arg.startswith("-q="):jq = arg.split("=", 1)[1]index += 1elif arg in {"--template", "-t"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")template = argv[index + 1]index += 2elif arg.startswith("--template=") or arg.startswith("-t="):template = arg.split("=", 1)[1]index += 1elif arg in {"--web", "-w"}:web = Trueindex += 1elif arg in {"--comments", "-c"}:comments = Trueindex += 1elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo issue view flag: {arg}")else:parsed_number, _ = _parse_number_or_branch(arg, kind="issue")number = parsed_numberindex += 1return IssueViewArgs(json_fields=json_fields, repo=repo, jq=jq, template=template, number=number, web=web, comments=comments)def _parse_issue_create_args(argv: list[str], *, stdin: TextIO | None = None) -> IssueCreateArgs:title = Nonebody = Nonerepo = Noneweb = Falseassignees: list[str] = []labels: list[str] = []milestone = Noneindex = 0while index < len(argv):arg = argv[index]if arg in {"--title", "-t"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")title = argv[index + 1]index += 2elif arg.startswith("--title=") or arg.startswith("-t="):title = arg.split("=", 1)[1]index += 1elif arg in {"--body", "-b"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")body = argv[index + 1]index += 2elif arg.startswith("--body=") or arg.startswith("-b="):body = arg.split("=", 1)[1]index += 1elif arg in {"--body-file", "-F"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")body = body_from_file(argv[index + 1], stdin=stdin)index += 2elif arg.startswith("--body-file=") or arg.startswith("-F="):body = body_from_file(arg.split("=", 1)[1], stdin=stdin)index += 1elif arg in {"-R", "--repo"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")repo = argv[index + 1]index += 2elif arg.startswith("--repo="):repo = arg.split("=", 1)[1]index += 1elif arg in {"--web", "-w"}:web = Trueindex += 1elif arg in {"--assignee", "-a"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")assignees.extend(_split_label_values(argv[index + 1]))index += 2elif arg.startswith("--assignee=") or arg.startswith("-a="):assignees.extend(_split_label_values(arg.split("=", 1)[1]))index += 1elif arg in {"--label", "-l"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")labels.extend(_split_label_values(argv[index + 1]))index += 2elif arg.startswith("--label=") or arg.startswith("-l="):labels.extend(_split_label_values(arg.split("=", 1)[1]))index += 1elif arg in {"--milestone", "-m"}:if index + 1 >= len(argv):raise ValueError(f"missing value for {arg}")milestone = _optional_int(argv[index + 1])index += 2elif arg.startswith("--milestone=") or arg.startswith("-m="):milestone = _optional_int(arg.split("=", 1)[1])index += 1elif arg in {"--editor", "-e", "--project", "-p", "--recover", "--template", "-T"}:raise ValueError(f"unsupported Forgejo issue create flag: {arg}")elif arg.startswith(("--project=", "-p=", "--recover=", "--template=", "-T=")):raise ValueError(f"unsupported Forgejo issue create flag: {arg.split('=', 1)[0]}")elif arg.startswith("-"):raise ValueError(f"unsupported Forgejo issue create flag: {arg}")else:raise ValueError(f"unexpected positional argument for Forgejo issue create: {arg}")return IssueCreateArgs(title=title,body=body,repo=repo,web=web,assignees=tuple(assignee for assignee in assignees if assignee != "@me"),labels=tuple(labels),milestone=milestone,)def _format_pull_text(data: dict[str, object]) -> str:number = data.get("number")title = data.get("title") or ""9 unmodified linesreturn f"{number}\t{title}\t{branch}".rstrip()def _format_issue_text(data: dict[str, object]) -> str:number = data.get("number")title = data.get("title") or ""state = data.get("state") or "UNKNOWN"url = data.get("url") or ""body = data.get("body") or ""return f"#{number} {title}\nstate: {state}\nurl: {url}\n\n{body}".rstrip()def _format_issue_list_item(data: dict[str, object]) -> str:number = data.get("number")title = data.get("title") or ""state = data.get("state") or ""return 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)62 unmodified linesif normalized not in {"open", "closed", "merged", "all"}:raise ValueError("--state must be one of: open, closed, merged, all")return normalizeddef _normalize_issue_state(value: str) -> str:normalized = value.lower()if normalized not in {"open", "closed", "all"}:raise ValueError("--state must be one of: open, closed, all")return normalizeddef _split_label_values(value: str) -> list[str]:return [item.strip() for item in value.split(",") if item.strip()]def _optional_int(value: str) -> int | None:try:return int(value)except ValueError:return Nonedef _resolve_label_ids(client: ForgejoClient, repo: RepoRef, labels: tuple[str, ...]) -> tuple[int, ...]:if not labels:return ()result: list[int] = []unresolved = [label for label in labels if _optional_int(label) is None]label_map: dict[str, int] = {}if unresolved:for label in client.list_labels(repo):raw_name = label.get("name")raw_id = label.get("id")if isinstance(raw_name, str) and isinstance(raw_id, int):label_map[raw_name] = raw_idfor label in labels:parsed = _optional_int(label)if parsed is not None:result.append(parsed)elif label in label_map:result.append(label_map[label])else:raise ValueError(f"could not resolve Forgejo issue label: {label}")return tuple(result)def _parse_number_or_branch(value: str, *, kind: str) -> tuple[int | None, str | None]:number = _number_from_url(value, kind=kind)if number is not None:return number, Noneif value.isdigit():return int(value), Nonereturn None, valuedef _number_from_url(value: str, *, kind: str) -> int | None:parsed = urllib.parse.urlparse(value)if parsed.scheme not in {"http", "https"}:return Noneparts = [part for part in parsed.path.strip("/").split("/") if part]markers = ("pulls", "pull") if kind == "pull" else ("issues", "issue")for index, part in enumerate(parts):if part in markers and index + 1 < len(parts) and parts[index + 1].isdigit():return int(parts[index + 1])return Nonedef _resolve_pull(repo: RepoRef,client: ForgejoClient,*,number: int | None,branch: str | None,cwd: str | None,) -> dict[str, object] | None:if number is not None:return client.get_pull(repo, number)target_branch = branch or current_branch(cwd=cwd)if not target_branch:return Nonepulls = client.list_pulls(repo, state="open", head=target_branch)return 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):return Noneif isinstance(raw, int):return rawif isinstance(raw, str) and raw.isdigit():return int(raw)return Nonedef _pull_head_ref(pull: dict[str, object]) -> str | None:head = pull.get("head")if not isinstance(head, dict):return Noneref = head.get("ref")return ref if isinstance(ref, str) and ref else Nonedef _filtered_file_names(files: list[dict[str, object]], excludes: tuple[str, ...]) -> list[str]:names = []for item in files:raw = item.get("filename") or item.get("name") or item.get("path")if not isinstance(raw, str) or _matches_any(raw, excludes):continuenames.append(raw)return namesdef _matches_any(value: str, patterns: tuple[str, ...]) -> bool:return any(fnmatch.fnmatch(value, pattern) for pattern in patterns)
38 unmodified lines39404142434438 unmodified linesdef test_plain_owner_repo_without_default_host_is_unknown(self) -> None:self.assertIsNone(parse_repo_spec("owner/repo"))if __name__ == "__main__":unittest.main()38 unmodified lines39404142434445464748495051525354555638 unmodified linesdef test_plain_owner_repo_without_default_host_is_unknown(self) -> None:self.assertIsNone(parse_repo_spec("owner/repo"))def test_web_pull_url_uses_repository_path_not_trailing_segments(self) -> None:repo = parse_repo_spec("https://git.example.com/owner/repo/pulls/7")self.assertIsNotNone(repo)assert repo is not Noneself.assertEqual((repo.host, repo.owner, repo.repo), ("git.example.com", "owner", "repo"))def test_web_issue_url_uses_repository_path_not_trailing_segments(self) -> None:repo = parse_repo_spec("https://git.example.com/owner/repo/issues/13")self.assertIsNotNone(repo)assert repo is not Noneself.assertEqual((repo.host, repo.owner, repo.repo), ("git.example.com", "owner", "repo"))if __name__ == "__main__":unittest.main()
11 unmodified lines121314151617181920212223242526272824 unmodified lines53545556575859606162636465666768697071219 unmodified lines29129229329429529621 unmodified lines318319320321322323110 unmodified lines43443543643743843911 unmodified linesclass FakeClient(ForgejoClient):def __init__(self, pulls: list[dict] | None = None) -> None:super().__init__(None)self.pulls = pulls or []self.created: dict | None = Noneself.repo_view = {"name": "repo","full_name": "owner/repo","html_url": "https://git.example.com/owner/repo","ssh_url": "git@git.example.com:owner/repo.git","default_branch": "main","private": False,"owner": {"login": "owner"},}24 unmodified linesreturn pullraise ValueError("not found")def get_repo(self, repo: RepoRef): # type: ignore[no-untyped-def]return self.repo_viewclass RoutingTests(unittest.TestCase):def test_delegates_non_pr_command(self) -> None:decision = decide_route(["issue", "list"],config=Config(hosts=("git.example.com",)),env={},)self.assertEqual(decision.kind, "delegate")def test_routes_pr_list_for_allowlisted_host(self) -> None:decision = decide_route(["pr", "list", "-R", "git.example.com/owner/repo"],219 unmodified linesself.assertEqual(code, 0)self.assertEqual(json.loads(out.getvalue()), [])def test_repo_view_outputs_github_shaped_json(self) -> None:out = io.StringIO()code = run_forgejo(21 unmodified lines},)def test_no_current_branch_view_returns_empty_json_success(self) -> None:with tempfile.TemporaryDirectory() as tmp:cwd = Path(tmp)110 unmodified linessubprocess.run(["git", *args], cwd=cwd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)if __name__ == "__main__":unittest.main()11 unmodified lines12131415161718192021222324252627282930313233343536373839404142434445464724 unmodified lines72737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143219 unmodified lines36336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445521 unmodified lines477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635110 unmodified lines74674774874975075175275375475575611 unmodified linesclass FakeClient(ForgejoClient):def __init__(self, pulls: list[dict] | None = None, issues: list[dict] | None = None) -> None:super().__init__(None)self.pulls = pulls or []self.issues = issues or []self.created: dict | None = Noneself.created_issue: dict | None = Noneself.comments: list[dict] = []self.diff_text = "diff --git a/README.md b/README.md\n"self.pull_files = [{"filename": "README.md"}, {"filename": "generated/output.txt"}]self.repo_view = {"id": 99,"name": "repo","full_name": "owner/repo","html_url": "https://git.example.com/owner/repo","ssh_url": "git@git.example.com:owner/repo.git","default_branch": "main","private": False,"has_issues": True,"has_projects": True,"has_wiki": False,"allow_merge_commits": True,"allow_rebase": True,"allow_squash_merge": True,"forks_count": 2,"stars_count": 3,"watchers_count": 4,"open_issues_count": 5,"open_pr_counter": 6,"language": "Python","permissions": {"pull": True, "push": True, "admin": False},"owner": {"login": "owner"},}24 unmodified linesreturn pullraise ValueError("not found")def get_pull_diff(self, repo: RepoRef, number: int, *, patch: bool = False): # type: ignore[no-untyped-def]return self.diff_textdef list_pull_files(self, repo: RepoRef, number: int): # type: ignore[no-untyped-def]return self.pull_filesdef get_repo(self, repo: RepoRef): # type: ignore[no-untyped-def]return self.repo_viewdef list_issues(self, repo: RepoRef, **kwargs): # type: ignore[no-untyped-def]return self.issuesdef get_issue(self, repo: RepoRef, number: int): # type: ignore[no-untyped-def]for issue in self.issues:if issue.get("number") == number:return issueraise ValueError("not found")def list_labels(self, repo: RepoRef): # type: ignore[no-untyped-def]return [{"id": 2, "name": "bug"}, {"id": 3, "name": "help wanted"}]def create_issue(self, repo: RepoRef, **kwargs): # type: ignore[no-untyped-def]self.created_issue = {"repo": repo, **kwargs}return {"number": 14,"title": kwargs["title"],"body": kwargs.get("body", ""),"state": "open","html_url": "https://git.example.com/owner/repo/issues/14","user": {"login": "alice"},}def create_issue_comment(self, repo: RepoRef, number: int, *, body: str): # type: ignore[no-untyped-def]comment = {"repo": repo, "number": number, "body": body}self.comments.append(comment)return commentclass RoutingTests(unittest.TestCase):def test_delegates_non_pr_command(self) -> None:decision = decide_route(["api", "repos/owner/repo"],config=Config(hosts=("git.example.com",)),env={},)self.assertEqual(decision.kind, "delegate")def test_routes_issue_list_for_allowlisted_host(self) -> None:decision = decide_route(["issue", "list", "-R", "git.example.com/owner/repo"],config=Config(hosts=("git.example.com",)),env={},)self.assertEqual(decision.kind, "forgejo")def test_routes_issue_view_url_for_allowlisted_host(self) -> None:decision = decide_route(["issue", "view", "https://git.example.com/owner/repo/issues/13"],config=Config(hosts=("git.example.com",)),env={},)self.assertEqual(decision.kind, "forgejo")self.assertIsNotNone(decision.repo)assert decision.repo is not Noneself.assertEqual((decision.repo.owner, decision.repo.repo), ("owner", "repo"))def test_routes_pr_list_for_allowlisted_host(self) -> None:decision = decide_route(["pr", "list", "-R", "git.example.com/owner/repo"],219 unmodified linesself.assertEqual(code, 0)self.assertEqual(json.loads(out.getvalue()), [])def test_pr_diff_outputs_forgejo_diff(self) -> None:out = io.StringIO()code = run_forgejo_pr(["pr", "diff", "8"],RepoRef("git.example.com", "owner", "repo"),FakeClient([{"number": 8,"title": "Make Codex happy","state": "open","html_url": "https://git.example.com/owner/repo/pulls/8","head": {"ref": "feature"},}]),stdout=out,stderr=io.StringIO(),)self.assertEqual(code, 0)self.assertIn("diff --git", out.getvalue())def test_pr_diff_name_only_filters_excluded_files(self) -> None:out = io.StringIO()code = run_forgejo_pr(["pr", "diff", "8", "--name-only", "--exclude", "generated/*"],RepoRef("git.example.com", "owner", "repo"),FakeClient([{"number": 8, "head": {"ref": "feature"}}]),stdout=out,stderr=io.StringIO(),)self.assertEqual(code, 0)self.assertEqual(out.getvalue().splitlines(), ["README.md"])def test_pr_comment_posts_body_to_issue_comments(self) -> None:client = FakeClient([{"number": 8, "head": {"ref": "feature"}}])out = io.StringIO()code = run_forgejo_pr(["pr", "comment", "8", "--body", "Looks good"],RepoRef("git.example.com", "owner", "repo"),client,stdout=out,stderr=io.StringIO(),)self.assertEqual(code, 0)self.assertEqual(out.getvalue(), "")self.assertEqual(client.comments[0]["number"], 8)self.assertEqual(client.comments[0]["body"], "Looks good")def test_pr_checkout_fetches_and_checks_out_head_branch(self) -> None:with tempfile.TemporaryDirectory() as tmp:root = Path(tmp)remote = root / "remote.git"seed = root / "seed"work = root / "work"self._git(root, "init", "--bare", str(remote))seed.mkdir()self._git(seed, "init")self._git(seed, "config", "user.email", "test@example.com")self._git(seed, "config", "user.name", "Test User")(seed / "README.md").write_text("main\n", encoding="utf-8")self._git(seed, "add", "README.md")self._git(seed, "commit", "-m", "initial")self._git(seed, "branch", "-M", "main")self._git(seed, "remote", "add", "origin", str(remote))self._git(seed, "push", "-u", "origin", "main")self._git(seed, "checkout", "-b", "feature")(seed / "README.md").write_text("feature\n", encoding="utf-8")self._git(seed, "commit", "-am", "feature")self._git(seed, "push", "origin", "feature")self._git(root, "--git-dir", str(remote), "symbolic-ref", "HEAD", "refs/heads/main")self._git(root, "clone", str(remote), str(work))self._git(work, "checkout", "main")out = io.StringIO()code = run_forgejo_pr(["pr", "checkout", "8"],RepoRef("git.example.com", "owner", "repo"),FakeClient([{"number": 8, "head": {"ref": "feature"}}]),cwd=str(work),stdout=out,stderr=io.StringIO(),)self.assertEqual(code, 0)self.assertEqual(self._git_output(work, "branch", "--show-current"), "feature")def test_repo_view_outputs_github_shaped_json(self) -> None:out = io.StringIO()code = run_forgejo(21 unmodified lines},)def test_repo_view_outputs_richer_github_json_fields(self) -> None:out = io.StringIO()code = run_forgejo(["repo","view","-R","git.example.com/owner/repo","--json","id,hasIssuesEnabled,hasProjectsEnabled,hasWikiEnabled,forkCount,stargazerCount,watchers,primaryLanguage,viewerPermission,visibility,pullRequests,issues,mergeCommitAllowed,rebaseMergeAllowed,squashMergeAllowed",],RepoRef("git.example.com", "owner", "repo"),FakeClient(),stdout=out,stderr=io.StringIO(),)self.assertEqual(code, 0)self.assertEqual(json.loads(out.getvalue()),{"forkCount": 2,"hasIssuesEnabled": True,"hasProjectsEnabled": True,"hasWikiEnabled": False,"id": 99,"issues": {"nodes": [], "totalCount": 5},"mergeCommitAllowed": True,"primaryLanguage": {"name": "Python"},"pullRequests": {"nodes": [], "totalCount": 6},"rebaseMergeAllowed": True,"squashMergeAllowed": True,"stargazerCount": 3,"viewerPermission": "WRITE","visibility": "PUBLIC","watchers": {"nodes": [], "totalCount": 4},},)def test_issue_list_outputs_github_shaped_json(self) -> None:out = io.StringIO()code = run_forgejo(["issue","list","-R","git.example.com/owner/repo","--state","all","--json","number,title,state,url,author,comments",],RepoRef("git.example.com", "owner", "repo"),FakeClient(issues=[{"number": 13,"title": "Fix it","state": "open","html_url": "https://git.example.com/owner/repo/issues/13","user": {"login": "alice"},"comments": 2,}]),stdout=out,stderr=io.StringIO(),)self.assertEqual(code, 0)self.assertEqual(json.loads(out.getvalue()),[{"author": {"login": "alice", "name": None},"comments": 2,"number": 13,"state": "OPEN","title": "Fix it","url": "https://git.example.com/owner/repo/issues/13",}],)def test_issue_view_outputs_github_shaped_json(self) -> None:out = io.StringIO()code = run_forgejo(["issue","view","13","-R","git.example.com/owner/repo","--json","number,title,body,state,closed",],RepoRef("git.example.com", "owner", "repo"),FakeClient(issues=[{"number": 13,"title": "Fix it","body": "Details","state": "closed","html_url": "https://git.example.com/owner/repo/issues/13",}]),stdout=out,stderr=io.StringIO(),)self.assertEqual(code, 0)self.assertEqual(json.loads(out.getvalue()),{"body": "Details","closed": True,"number": 13,"state": "CLOSED","title": "Fix it",},)def test_issue_create_calls_forgejo_client(self) -> None:client = FakeClient()out = io.StringIO()code = run_forgejo(["issue","create","-R","git.example.com/owner/repo","--title","Bug","--body","It broke","--assignee","alice,bob","--label","bug,3",],RepoRef("git.example.com", "owner", "repo"),client,stdout=out,stderr=io.StringIO(),)self.assertEqual(code, 0)self.assertIsNotNone(client.created_issue)assert client.created_issue is not Noneself.assertEqual(client.created_issue["title"], "Bug")self.assertEqual(client.created_issue["body"], "It broke")self.assertEqual(client.created_issue["assignees"], ("alice", "bob"))self.assertEqual(client.created_issue["labels"], (2, 3))self.assertEqual(out.getvalue().strip(), "https://git.example.com/owner/repo/issues/14")def test_no_current_branch_view_returns_empty_json_success(self) -> None:with tempfile.TemporaryDirectory() as tmp:cwd = Path(tmp)110 unmodified linessubprocess.run(["git", *args], cwd=cwd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)def _git_output(self, cwd: Path, *args: str) -> str:import subprocessreturn subprocess.check_output(["git", *args], cwd=cwd, text=True, stderr=subprocess.DEVNULL).strip()if __name__ == "__main__":unittest.main()
GitHub-oriented tools should get farther in Forgejo repositories before deciding that GitHub CLI support is missing. Users can now list and inspect Forgejo issues, create issues, comment on PRs, view PR diffs, check out PR branches, and request richer repository JSON through familiar gh commands.
PYTHONPATH=src python3 -m unittest: passed, 72 tests.python3 -m compileall src: passed.git diff --check: passed.bd dolt push: exited 0 and skipped remote sync because no Beads Dolt remote is configured.@pierre/diffs@1.2.5 and completed successfully.gh pr checks remains intentionally minimal. It returns parse-safe empty check data because Forgejo Actions data is not GitHub Actions check-rollup data.gh pr comment does not implement editing or deleting the last comment. Unsupported mutating comment modes fail with a clear Forgejo-specific error.gh issue create supports title, body, body files, assignees, milestones, labels, repo selection, and web mode. GitHub projects, templates, recover mode, and editor prompts remain unsupported.gh pr checkout assumes the repository has a usable origin remote or Forgejo-style PR refs. The bootstrap and README still document the recommended remote shape.bd dolt pull failed at session start with no remote, and bd dolt push skipped remote sync for the same reason. Beads local state is still stored under .beads/.No follow-up Beads issue was required for this task. Useful future work would be live Forgejo integration coverage for the new issue and checkout commands, plus broader support if a real tool starts probing PR reviews, release data, or issue comments.