From 403f3a3728aaf053ce31db57a3ae3d200a3f8e3b Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 15:21:43 -0500 Subject: [PATCH 01/29] initial check-endpoints refactor and output comparison --- check-endpoints-output.md | 101 +++++++++ check-endpoints.py | 445 +++++++++++++++++++++++++++++++++----- 2 files changed, 495 insertions(+), 51 deletions(-) create mode 100644 check-endpoints-output.md mode change 100755 => 100644 check-endpoints.py diff --git a/check-endpoints-output.md b/check-endpoints-output.md new file mode 100644 index 0000000..8edbae7 --- /dev/null +++ b/check-endpoints-output.md @@ -0,0 +1,101 @@ +# Old output: + +Missing endpoints: + +/api/board/game/{gameId}/claim-draw +/api/bot/game/{gameId}/claim-draw +/api/bot/game/{gameId}/claim-victory +/api/broadcast/round/{broadcastRoundId}/reset +/api/bulk-pairing/{id}/games +/api/challenge/{challengeId}/show +/api/fide/player/{playerId}/ratings +/api/games/export/bookmarks +/api/puzzle/batch/{angle} +/api/puzzle/replay/{days}/{theme} +/api/racer/{id} +/api/study/by/{username}/export.pgn +/api/study/{studyId}/{chapterId}/tags +/api/user/{username}/note +/api/user/{username}/tournament/played +/broadcast/{broadcastTournamentId}/players +/broadcast/{broadcastTournamentId}/players/{playerId} +/broadcast/{broadcastTournamentId}/teams/standings +/oauth +https://tablebase.lichess.ovh/antichess +https://tablebase.lichess.ovh/atomic +https://tablebase.lichess.ovh/standard + +(those tablebase endpoints are false positives, but the script isn't correctly filtering them out) + +# New output: + +Exit codes: 0 = no missing; 1 = has missing endpoints or params; 2 = error (e.g. spec file not found). + +Missing (path, operation): + + /api/board/game/{gameId}/claim-draw POST + /api/bot/game/{gameId}/chat GET + /api/bot/game/{gameId}/claim-draw POST + /api/bot/game/{gameId}/claim-victory POST + /api/broadcast/round/{broadcastRoundId}/reset POST + /api/broadcast/{broadcastTournamentSlug}/{broadcastRoundSlug}/{broadcastRoundId} GET + /api/bulk-pairing/{id} GET + /api/bulk-pairing/{id}/games GET + /api/challenge/{challengeId}/show GET + /api/fide/player/{playerId}/ratings GET + /api/games/export/bookmarks GET + /api/puzzle/batch/{angle} GET + /api/puzzle/batch/{angle} POST + /api/puzzle/replay/{days}/{theme} GET + /api/racer/{id} GET + /api/study/by/{username}/export.pgn GET + /api/study/{studyId}/{chapterId} DELETE + /api/study/{studyId}/{chapterId}/tags POST + /api/token DELETE + /api/token POST + /api/tournament/{id} POST + /api/user/{username}/note GET + /api/user/{username}/note POST + /api/user/{username}/tournament/played GET + /broadcast/{broadcastTournamentId}/players GET + /broadcast/{broadcastTournamentId}/players/{playerId} GET + /broadcast/{broadcastTournamentId}/teams/standings GET + +Missing query params (implemented endpoints): + + /api/broadcast GET missing params: ['html'] (berserk/clients/broadcasts.py: Broadcasts.get_official) + /api/games/export/_ids POST missing params: ['accuracy', 'division', 'literate', 'pgnInJson'] (berserk/clients/games.py: Games.export_multi) + /api/games/user/{username} GET missing params: ['accuracy', 'division', 'lastFen', 'withBookmarked'] (berserk/clients/games.py: Games.export_by_player) + /api/puzzle/activity GET missing params: ['since'] (berserk/clients/puzzles.py: Puzzles.get_puzzle_activity) + /api/puzzle/next GET missing params: ['color'] (berserk/clients/puzzles.py: Puzzles.get_next) + /api/swiss/{id}/games GET missing params: ['accuracy', 'division', 'moves', 'player'] (berserk/clients/tournaments.py: Tournaments.export_swiss_games) + /api/team/{teamId}/arena GET missing params: ['createdBy', 'name', 'status'] (berserk/clients/tournaments.py: Tournaments.arenas_by_team) + /api/team/{teamId}/swiss GET missing params: ['createdBy', 'name', 'status'] (berserk/clients/tournaments.py: Tournaments.swiss_by_team) + /api/tournament/{id}/games GET missing params: ['accuracy', 'division', 'pgnInJson', 'player'] (berserk/clients/tournaments.py: Tournaments.export_arena_games) + /api/user/{username}/current-game GET missing params: ['accuracy', 'division'] (berserk/clients/games.py: Games.export_ongoing_by_player) + /api/user/{username}/tournament/created GET missing params: ['status'] (berserk/clients/tournaments.py: Tournaments.tournaments_by_user) + /game/export/{gameId} GET missing params: ['accuracy', 'division', 'withBookmarked'] (berserk/clients/games.py: Games.export) + https://explorer.lichess.ovh/player GET missing params: ['modes'] (berserk/clients/opening_explorer.py: OpeningExplorer.stream_player_games) + + JSON output (with `--json`; see exit codes above): + + { + "missing_endpoints": [ + { + "path": "/api/board/game/{gameId}/claim-draw", + "operation": "POST" + }, + ... + ], + "missing_params": [ + { + "path": "/api/broadcast", + "operation": "GET", + "params": [ + "html" + ], + "method": "berserk/clients/broadcasts.py: Broadcasts.get_official" + }, + ... + ] +} \ No newline at end of file diff --git a/check-endpoints.py b/check-endpoints.py old mode 100755 new mode 100644 index 4d509d0..652166d --- a/check-endpoints.py +++ b/check-endpoints.py @@ -1,59 +1,402 @@ #!/usr/bin/env python3 +"""Check that client code implements all (path, operation) pairs from the API spec. + +Uses AST parsing to find path assignments and _r.get/post/put/patch/delete/request +calls so we match (path, operation) accurately, including when path is passed +as a variable or as a literal/f-string. For implemented endpoints, also checks +that query params passed to the request match the spec. + +Exit codes: 0 = no missing endpoints or params; 1 = has missing (for CI/alerting); +2 = error (bad args, spec file not found, etc.). With --json, only JSON is printed +to stdout for machine consumption; errors always go to stderr. +""" from __future__ import annotations -import yaml +import ast +import json import re import sys -import pathlib - -# tablebase endpoints, defined dynamically with "/{variant}" in the code -FALSE_POSITIVES = ["/standard", "/atomic", "/antichess"] - -if len(sys.argv) != 2 or not pathlib.Path(sys.argv[1]).is_file(): - path = "../api/doc/specs/lichess-api.yaml" - if not pathlib.Path(path).is_file(): - print( - "Usage: check-endpoints.py", - "", - ) - exit(1) -else: - path = sys.argv[1] - - -with open(path) as f: - spec = yaml.load(f, Loader=yaml.SafeLoader) - -clients_content = "\n".join( - p.read_text() for p in pathlib.Path("berserk/clients/").glob("*.py") -) - -missing_endpoints: list[str] = [] - -for endpoint, data in spec["paths"].items(): - # Remove leading slash - endpoint_without_slash = endpoint[1:] - - # Replace parameter placeholders with regular expression - # Encode/decode methods allow to treat it as raw string: https://stackoverflow.com/questions/2428117/casting-raw-strings-python/2428132#2428132 - endpoint_regex = ( - f"/{re.sub(r'{[^/]+?}', r'[^/]+?', endpoint_without_slash)}".encode( - "unicode_escape" - ).decode() +from pathlib import Path + +import yaml + +# Exit codes: 0 = no missing, 1 = has missing (endpoints or params), 2 = error (usage/file) +EXIT_OK = 0 +EXIT_HAS_MISSING = 1 +EXIT_ERROR = 2 + +# Paths that appear in spec but are implemented dynamically (e.g. tablebase /{variant}) +FALSE_POSITIVES = {"/standard", "/atomic", "/antichess", "/oauth"} + +# HTTP methods we care about (OpenAPI operation keys) +OPERATIONS = ("get", "post", "put", "patch", "delete") + + +def _dict_literal_keys(node: ast.Dict) -> set[str]: + """Return set of string keys from a dict literal. Non-string keys are skipped.""" + keys: set[str] = set() + for k in node.keys: + if k is not None and isinstance(k, ast.Constant) and isinstance(k.value, str): + keys.add(k.value) + return keys + + +def param_keys_from_node( + node: ast.AST | None, params_scope: dict[str, set[str]] +) -> set[str] | None: + """Resolve param keys from AST: literal dict, or Name looked up in params_scope. None = unresolved.""" + if node is None: + return set() + if isinstance(node, ast.Dict): + return _dict_literal_keys(node) + if isinstance(node, ast.Name): + return params_scope.get(node.id) + return None + + +def query_param_keys_from_path(path_template: str | None) -> set[str]: + """Extract query param names from a path string that may contain ?key=value&... (e.g. f\"...?page={page}\").""" + if not path_template or "?" not in path_template: + return set() + query_part = path_template.split("?", 1)[1].split("#")[0] + keys: set[str] = set() + for pair in query_part.split("&"): + if "=" in pair: + keys.add(pair.split("=", 1)[0].strip()) + elif pair.strip(): + keys.add(pair.strip()) + return keys + + +def normalize_path_template(path: str) -> str: + """Canonical form for matching: leading slash, placeholders as {}, no trailing slash.""" + # Query string is not part of the path for spec matching + if "?" in path: + path = path.split("?", 1)[0] + path = path.strip().rstrip("/") + if path and not path.startswith("/"): + path = "/" + path + # Collapse any {param} to {} so spec and code match regardless of param names + return re.sub(r"\{[^}/]+\}", "{}", path) + + +def path_template_from_ast_node(node: ast.AST, scope: dict[str, str]) -> str | None: + """Resolve a path value from an AST node (Constant, JoinedStr, or Name in scope).""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.JoinedStr): + parts = [] + for value in node.values: + if isinstance(value, ast.Constant) and isinstance(value.value, str): + parts.append(value.value) + elif isinstance(value, ast.FormattedValue): + # Any placeholder; we don't need the variable name for matching + parts.append("{}") + else: + return None + return "".join(parts) + if isinstance(node, ast.Name): + return scope.get(node.id) + return None + + +class PathOperationVisitor(ast.NodeVisitor): + """Collect (normalized_path, operation), param keys, and method location from _r.get/post/.../request calls.""" + + def __init__(self, source_file: Path | None = None) -> None: + self.found: set[tuple[str, str]] = set() + self.implemented_params: dict[tuple[str, str], set[str]] = {} + self.implemented_method_info: dict[tuple[str, str], tuple[str, str, str]] = {} + self._scope: dict[str, str] = {} + self._params_scope: dict[str, set[str]] = {} + self._source_file = source_file + self._current_class: str | None = None + self._current_function: ast.FunctionDef | None = None + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + old_class = self._current_class + self._current_class = node.name + self.generic_visit(node) + self._current_class = old_class + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + old_scope = self._scope.copy() + old_params_scope = self._params_scope.copy() + old_function = self._current_function + self._current_function = node + # First pass: collect path assignments and params dict assignments + for stmt in node.body: + if isinstance(stmt, ast.Assign): + for target in stmt.targets: + if isinstance(target, ast.Name): + template = path_template_from_ast_node(stmt.value, self._scope) + if template is not None: + self._scope[target.id] = template + if isinstance(stmt.value, ast.Dict): + self._params_scope[target.id] = _dict_literal_keys( + stmt.value + ) + self.generic_visit(node) + self._scope = old_scope + self._params_scope = old_params_scope + self._current_function = old_function + + def _add( + self, + path_template: str | None, + operation: str, + param_keys: set[str] | None, + ) -> None: + if path_template is None: + return + normalized = normalize_path_template(path_template) + if not normalized: + return + op = operation.lower() + key = (normalized, op) + self.found.add(key) + # Record first-seen method location for this (path, op) + if key not in self.implemented_method_info and self._source_file is not None: + class_name = self._current_class or "" + method_name = self._current_function.name if self._current_function else "" + self.implemented_method_info[key] = ( + str(self._source_file), + class_name, + method_name, + ) + # Param keys from params= dict plus any interpolated in path (?key=...) + keys = set(param_keys) if param_keys else set() + keys |= query_param_keys_from_path(path_template) + if keys: + self.implemented_params.setdefault(key, set()).update(keys) + + def visit_Call(self, node: ast.Call) -> None: + # Detect self._r.get(...), self._r.post(...), self._r.request(...) + if not isinstance(node.func, ast.Attribute): + self.generic_visit(node) + return + attr = node.func.attr + if not isinstance(node.func.value, ast.Attribute): + self.generic_visit(node) + return + if node.func.value.attr != "_r": + self.generic_visit(node) + return + # self._r.(...) + params_node = None + for kw in node.keywords: + if kw.arg == "params": + params_node = kw.value + break + + if attr in ("get", "post", "put", "patch", "delete"): + # First positional is path, or path= keyword + path_node = None + for i, arg in enumerate(node.args): + if i == 0: + path_node = arg + break + if path_node is None: + for kw in node.keywords: + if kw.arg == "path": + path_node = kw.value + break + if path_node is not None: + template = path_template_from_ast_node(path_node, self._scope) + param_keys = param_keys_from_node(params_node, self._params_scope) + self._add(template, attr, param_keys) + elif attr == "request": + # request(method, path, ...) or request(method=..., path=...) + method_val = None + path_node = None + if len(node.args) >= 2: + method_val = node.args[0] + path_node = node.args[1] + for kw in node.keywords: + if kw.arg == "method": + method_val = kw.value + elif kw.arg == "path": + path_node = kw.value + if path_node is not None and method_val is not None: + if isinstance(method_val, ast.Constant) and isinstance( + method_val.value, str + ): + template = path_template_from_ast_node(path_node, self._scope) + param_keys = param_keys_from_node( + params_node, self._params_scope + ) + self._add(template, method_val.value.lower(), param_keys) + self.generic_visit(node) + + +def discover_implemented_path_operations( + clients_dir: Path, +) -> tuple[ + set[tuple[str, str]], + dict[tuple[str, str], set[str]], + dict[tuple[str, str], tuple[str, str, str]], +]: + """Parse all client modules; return (implemented (path, op), param keys, method info).""" + implemented: set[tuple[str, str]] = set() + implemented_params: dict[tuple[str, str], set[str]] = {} + implemented_method_info: dict[tuple[str, str], tuple[str, str, str]] = {} + for py_path in sorted(clients_dir.glob("*.py")): + try: + tree = ast.parse(py_path.read_text()) + except SyntaxError: + continue + visitor = PathOperationVisitor(source_file=py_path) + visitor.visit(tree) + implemented |= visitor.found + for key, keys in visitor.implemented_params.items(): + implemented_params.setdefault(key, set()).update(keys) + for key, info in visitor.implemented_method_info.items(): + if key not in implemented_method_info: + implemented_method_info[key] = info + return (implemented, implemented_params, implemented_method_info) + + +def spec_query_params(path_item: dict, op: str) -> set[str]: + """Return set of query param names for this path + operation (path-level and operation-level merged).""" + path_params = path_item.get("parameters") or [] + op_obj = path_item.get(op) + op_params = (op_obj.get("parameters") or []) if isinstance(op_obj, dict) else [] + by_name: dict[str, dict] = {} + for p in path_params: + if isinstance(p, dict) and p.get("name"): + by_name[p["name"]] = p + for p in op_params: + if isinstance(p, dict) and p.get("name"): + by_name[p["name"]] = p + return {p["name"] for p in by_name.values() if p.get("in") == "query"} + + +def spec_path_operations(spec: dict): + """Yield (path_str, path_item, operation) from OpenAPI spec paths.""" + for path_str, path_item in spec.get("paths", {}).items(): + if not isinstance(path_item, dict): + continue + for op in OPERATIONS: + if op in path_item: + yield (path_str, path_item, op) + + +def main() -> None: + argv = sys.argv[1:] + json_output = "--json" in argv + args = [a for a in argv if a != "--json"] + + if len(args) != 1: + spec_path = Path("../api/doc/specs/lichess-api.yaml") + if not spec_path.is_file(): + print( + "Usage: check-endpoints-NEW.py [--json] ", + file=sys.stderr, + ) + sys.exit(EXIT_ERROR) + else: + spec_path = Path(args[0]) + if not spec_path.is_file(): + print(f"Spec file not found: {spec_path}", file=sys.stderr) + sys.exit(EXIT_ERROR) + + try: + with open(spec_path) as f: + spec = yaml.load(f, Loader=yaml.SafeLoader) + except OSError as e: + print(f"Error reading spec: {e}", file=sys.stderr) + sys.exit(EXIT_ERROR) + + clients_dir = Path("berserk/clients") + if not clients_dir.is_dir(): + print("Run from repo root (berserk/clients must exist).", file=sys.stderr) + sys.exit(EXIT_ERROR) + + implemented, implemented_params, implemented_method_info = ( + discover_implemented_path_operations(clients_dir) ) - # Check if endpoint or a variation of it is present in file - if not re.search(endpoint_regex, clients_content): - if servers := data.get("servers"): - if host := servers[0].get("url"): - endpoint = host + endpoint - missing_endpoints.append(endpoint) - -if missing_endpoints: - print("\nMissing endpoints:\n") - for endp in sorted(missing_endpoints): - if endp not in FALSE_POSITIVES: - print(endp) -else: - print("No missing endpoints") + def _format_method(file: str, class_name: str, method_name: str) -> str: + if class_name and method_name: + return f"{file}: {class_name}.{method_name}" + if method_name: + return f"{file}: {method_name}" + return file + + missing: list[tuple[str, str]] = [] + for path_str, path_item, op in spec_path_operations(spec): + path_norm = normalize_path_template(path_str) + if path_norm in FALSE_POSITIVES: + continue + if (path_norm, op) not in implemented: + # When path has its own servers (e.g. tablebase), show full URL in output + if servers := path_item.get("servers"): + if host := servers[0].get("url"): + path_str = host + path_str + missing.append((path_str, op)) + + missing_params_list: list[tuple[str, str, set[str], str]] = [] + for path_str, path_item, op in spec_path_operations(spec): + path_norm = normalize_path_template(path_str) + if path_norm in FALSE_POSITIVES: + continue + if (path_norm, op) not in implemented: + continue + spec_params = spec_query_params(path_item, op) + if not spec_params: + continue + implemented_keys = implemented_params.get((path_norm, op)) + if implemented_keys is None: + continue + missing_params = spec_params - implemented_keys + if missing_params: + if servers := path_item.get("servers"): + if host := servers[0].get("url"): + path_str = host + path_str + info = implemented_method_info.get((path_norm, op), ("", "", "")) + method_str = _format_method(info[0], info[1], info[2]) + missing_params_list.append((path_str, op, missing_params, method_str)) + + has_missing = bool(missing) or bool(missing_params_list) + + if json_output: + out = { + "missing_endpoints": [ + {"path": p, "operation": op.upper()} + for p, op in sorted(missing) + ], + "missing_params": [ + { + "path": p, + "operation": op.upper(), + "params": sorted(ps), + "method": method_str, + } + for p, op, ps, method_str in sorted( + missing_params_list, key=lambda x: (x[0], x[1]) + ) + ], + } + print(json.dumps(out, indent=2)) + else: + if missing: + print("\nMissing (path, operation):\n") + for path, op in sorted(missing): + print(f" {path} {op.upper()}") + else: + print("No missing endpoints") + if missing_params_list: + print("\nMissing query params (implemented endpoints):\n") + for path, op, params, method_str in sorted( + missing_params_list, key=lambda x: (x[0], x[1]) + ): + print( + f" {path} {op.upper()} missing params: {sorted(params)} ({method_str})" + ) + + sys.exit(EXIT_HAS_MISSING if has_missing else EXIT_OK) + + +if __name__ == "__main__": + main() From f7c7adad8d1a984c5327427d3155bbd451f0f039 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 15:22:36 -0500 Subject: [PATCH 02/29] format --- check-endpoints.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/check-endpoints.py b/check-endpoints.py index 652166d..2c27b38 100644 --- a/check-endpoints.py +++ b/check-endpoints.py @@ -223,9 +223,7 @@ def visit_Call(self, node: ast.Call) -> None: method_val.value, str ): template = path_template_from_ast_node(path_node, self._scope) - param_keys = param_keys_from_node( - params_node, self._params_scope - ) + param_keys = param_keys_from_node(params_node, self._params_scope) self._add(template, method_val.value.lower(), param_keys) self.generic_visit(node) @@ -363,8 +361,7 @@ def _format_method(file: str, class_name: str, method_name: str) -> str: if json_output: out = { "missing_endpoints": [ - {"path": p, "operation": op.upper()} - for p, op in sorted(missing) + {"path": p, "operation": op.upper()} for p, op in sorted(missing) ], "missing_params": [ { From ab3283536f5ade220117eb7eadd837fa9c190bb3 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 15:24:11 -0500 Subject: [PATCH 03/29] fix example script usage filename --- check-endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check-endpoints.py b/check-endpoints.py index 2c27b38..4ce2a41 100644 --- a/check-endpoints.py +++ b/check-endpoints.py @@ -289,7 +289,7 @@ def main() -> None: spec_path = Path("../api/doc/specs/lichess-api.yaml") if not spec_path.is_file(): print( - "Usage: check-endpoints-NEW.py [--json] ", + "Usage: check-endpoints.py [--json] ", file=sys.stderr, ) sys.exit(EXIT_ERROR) From c9c163767a090f8c7dc9d10a4402d314c0706203 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 16:15:17 -0500 Subject: [PATCH 04/29] initial check-endpoints workflow --- .github/workflows/check-endpoints.yml | 115 ++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/check-endpoints.yml diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml new file mode 100644 index 0000000..36ad768 --- /dev/null +++ b/.github/workflows/check-endpoints.yml @@ -0,0 +1,115 @@ +# Spec vs implementation: run check-endpoints.py against the latest Lichess API spec, +# and create or update a single issue with a checklist of missing endpoints/params. +# See also: issue #6 (historical umbrella), Jira CHESS-xxx Rolling CI. + +# When enabling schedule: uncomment the schedule block below. +# Duration: weekly. Cron "0 3 * * 1" = Monday 03:00 UTC (avoids overlap with 02:00 cassette refresh if present). +# GitHub does not support variables in cron; change the cron string if you want a different interval (e.g. "0 3 * * 1" weekly, "0 3 1 * *" monthly). +name: Check endpoints (spec vs implementation) + +on: + workflow_dispatch: {} + # Weekly run — uncomment to enable: + # schedule: + # - cron: "0 3 * * 1" + +jobs: + check-endpoints: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install PyYAML + run: pip install pyyaml + + - name: Fetch API spec + run: | + mkdir -p spec + curl -fsSL -o spec/openapi.yaml "https://lichess.org/api/openapi.yaml" + + - name: Run check-endpoints + id: check + run: | + python check-endpoints.py --json spec/openapi.yaml > output.json 2>stderr.txt + EXIT=$? + echo "exit_code=$EXIT" >> "$GITHUB_OUTPUT" + continue-on-error: true + + - name: Fail on script error (exit 2) + if: steps.check.outputs.exit_code == '2' + run: | + echo "check-endpoints.py failed (e.g. spec not found). stderr:" + cat stderr.txt + exit 1 + + - name: Build issue body and create or update issue (missing endpoints) + if: steps.check.outputs.exit_code == '1' + env: + GH_TOKEN: ${{ github.token }} + run: | + BODY=$(python3 - << 'PY' + import json + import sys + with open("output.json") as f: + data = json.load(f) + intro = """This issue is automatically updated by the [check-endpoints](.github/workflows/check-endpoints.yml) workflow. Manual edits to the list below will be overwritten. For historical context and implementation notes, see issue 6. + + *Note: The text above and the checklist below are for testing only (e.g. when this workflow runs on a fork).* + + """ + parts = [intro] + if data.get("missing_endpoints"): + parts.append("## Missing endpoints (path, operation)\n\n") + for e in data["missing_endpoints"]: + path, op = e["path"], e["operation"] + parts.append(f"- [ ] `{op}` `{path}`\n") + parts.append("\n") + if data.get("missing_params"): + parts.append("## Missing query params (implemented endpoints)\n\n") + for p in data["missing_params"]: + path, op = p["path"], p["operation"] + params = ", ".join(f"`{x}`" for x in p["params"]) + method = p.get("method", "") + parts.append(f"- [ ] `{op}` `{path}` — missing: {params} — {method}\n") + sys.stdout.write("".join(parts)) + PY + ) + echo "$BODY" > body.md + # Find open issue with our title + ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints (auto-updated)"' 2>/dev/null || true) + if [ -n "$ISSUE_NUM" ]; then + gh issue edit "$ISSUE_NUM" --body-file body.md + echo "Updated issue #$ISSUE_NUM" + else + # Reopen if closed: find any issue with that title + OLD_NUM=$(gh issue list --repo "${{ github.repository }}" --state all --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints (auto-updated)"' 2>/dev/null || true) + if [ -n "$OLD_NUM" ]; then + gh issue reopen "$OLD_NUM" + gh issue edit "$OLD_NUM" --body-file body.md + echo "Reopened and updated issue #$OLD_NUM" + else + gh issue create --title "Spec vs implementation: missing endpoints (auto-updated)" --body-file body.md + echo "Created new issue" + fi + fi + + - name: Close issue if no missing endpoints + if: steps.check.outputs.exit_code == '0' + env: + GH_TOKEN: ${{ github.token }} + run: | + ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints (auto-updated)"' 2>/dev/null || true) + if [ -n "$ISSUE_NUM" ]; then + gh issue close "$ISSUE_NUM" --comment "No missing endpoints or params as of this run. This issue will be reopened when the spec gains new endpoints we don't implement." + echo "Closed issue #$ISSUE_NUM" + fi From a14a8fb093531cc0ae4b065ff0c6ab3eae284990 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 16:42:10 -0500 Subject: [PATCH 05/29] add push trigger for testing on branch --- .github/workflows/check-endpoints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 36ad768..6bd8313 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -9,6 +9,11 @@ name: Check endpoints (spec vs implementation) on: workflow_dispatch: {} + push: + branches: ["check-endpoints-testing"] + paths: + - ".github/workflows/check-endpoints.yml" + - "check-endpoints.py" # Weekly run — uncomment to enable: # schedule: # - cron: "0 3 * * 1" From ac01c9eba930ea4e48e8443d5a233e0b30eb6dc8 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 16:52:07 -0500 Subject: [PATCH 06/29] update exit code handling --- .github/workflows/check-endpoints.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 6bd8313..95c67ed 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -45,10 +45,13 @@ jobs: - name: Run check-endpoints id: check run: | + set +e python check-endpoints.py --json spec/openapi.yaml > output.json 2>stderr.txt EXIT=$? + set -e echo "exit_code=$EXIT" >> "$GITHUB_OUTPUT" - continue-on-error: true + # Exit 0 so this step is green; exit_code in output drives later steps. Only exit 2 is a real failure. + [ "$EXIT" = "2" ] && exit 2 || exit 0 - name: Fail on script error (exit 2) if: steps.check.outputs.exit_code == '2' From 1eb4f1055ffd1baebbf4ada64a0249196312445a Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 16:58:06 -0500 Subject: [PATCH 07/29] whitespace trigger --- .github/workflows/check-endpoints.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 95c67ed..84084bc 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -121,3 +121,4 @@ jobs: gh issue close "$ISSUE_NUM" --comment "No missing endpoints or params as of this run. This issue will be reopened when the spec gains new endpoints we don't implement." echo "Closed issue #$ISSUE_NUM" fi + From e0e7002635f0ea2b84c1deb3a20fb691ca8919df Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 17:02:12 -0500 Subject: [PATCH 08/29] use bullets not checklist --- .github/workflows/check-endpoints.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 84084bc..0341929 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -80,7 +80,7 @@ jobs: parts.append("## Missing endpoints (path, operation)\n\n") for e in data["missing_endpoints"]: path, op = e["path"], e["operation"] - parts.append(f"- [ ] `{op}` `{path}`\n") + parts.append(f"- `{op}` `{path}`\n") parts.append("\n") if data.get("missing_params"): parts.append("## Missing query params (implemented endpoints)\n\n") @@ -88,7 +88,7 @@ jobs: path, op = p["path"], p["operation"] params = ", ".join(f"`{x}`" for x in p["params"]) method = p.get("method", "") - parts.append(f"- [ ] `{op}` `{path}` — missing: {params} — {method}\n") + parts.append(f"- `{op}` `{path}` — missing: {params} — {method}\n") sys.stdout.write("".join(parts)) PY ) From 6ac0df2026014e3da2173577197c7a344f73b07e Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 17:03:03 -0500 Subject: [PATCH 09/29] change checklist wording --- .github/workflows/check-endpoints.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 0341929..804fb92 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -1,5 +1,5 @@ # Spec vs implementation: run check-endpoints.py against the latest Lichess API spec, -# and create or update a single issue with a checklist of missing endpoints/params. +# and create or update a single issue with a list of missing endpoints/params. # See also: issue #6 (historical umbrella), Jira CHESS-xxx Rolling CI. # When enabling schedule: uncomment the schedule block below. @@ -72,7 +72,7 @@ jobs: data = json.load(f) intro = """This issue is automatically updated by the [check-endpoints](.github/workflows/check-endpoints.yml) workflow. Manual edits to the list below will be overwritten. For historical context and implementation notes, see issue 6. - *Note: The text above and the checklist below are for testing only (e.g. when this workflow runs on a fork).* + *Note: The text above and the list below are for testing only (e.g. when this workflow runs on a fork).* """ parts = [intro] From cd9cc83c0b93bc2c42321dee5737d8dbe224940e Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 17:08:07 -0500 Subject: [PATCH 10/29] update issue title --- .github/workflows/check-endpoints.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 804fb92..5259f8f 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -94,19 +94,19 @@ jobs: ) echo "$BODY" > body.md # Find open issue with our title - ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints (auto-updated)"' 2>/dev/null || true) + ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) if [ -n "$ISSUE_NUM" ]; then gh issue edit "$ISSUE_NUM" --body-file body.md echo "Updated issue #$ISSUE_NUM" else # Reopen if closed: find any issue with that title - OLD_NUM=$(gh issue list --repo "${{ github.repository }}" --state all --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints (auto-updated)"' 2>/dev/null || true) + OLD_NUM=$(gh issue list --repo "${{ github.repository }}" --state all --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) if [ -n "$OLD_NUM" ]; then gh issue reopen "$OLD_NUM" gh issue edit "$OLD_NUM" --body-file body.md echo "Reopened and updated issue #$OLD_NUM" else - gh issue create --title "Spec vs implementation: missing endpoints (auto-updated)" --body-file body.md + gh issue create --title "Spec vs implementation: missing endpoints or params (auto-updated)" --body-file body.md echo "Created new issue" fi fi @@ -116,7 +116,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints (auto-updated)"' 2>/dev/null || true) + ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) if [ -n "$ISSUE_NUM" ]; then gh issue close "$ISSUE_NUM" --comment "No missing endpoints or params as of this run. This issue will be reopened when the spec gains new endpoints we don't implement." echo "Closed issue #$ISSUE_NUM" From bcdff3da6672e40e3bf7d3e3dd58bc07502e225e Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 17:13:54 -0500 Subject: [PATCH 11/29] add daily cron for testing --- .github/workflows/check-endpoints.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 5259f8f..89cefed 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -9,12 +9,15 @@ name: Check endpoints (spec vs implementation) on: workflow_dispatch: {} - push: - branches: ["check-endpoints-testing"] - paths: - - ".github/workflows/check-endpoints.yml" - - "check-endpoints.py" - # Weekly run — uncomment to enable: + # push: + # branches: ["check-endpoints-testing"] + # paths: + # - ".github/workflows/check-endpoints.yml" + # - "check-endpoints.py" + # Daily run for testing — ~6 PM EST (23:00 UTC). During EDT it runs ~7 PM local. + schedule: + - cron: "0 23 * * *" + # Weekly (e.g. for production): "0 3 * * 1" = Monday 03:00 UTC # schedule: # - cron: "0 3 * * 1" @@ -121,4 +124,3 @@ jobs: gh issue close "$ISSUE_NUM" --comment "No missing endpoints or params as of this run. This issue will be reopened when the spec gains new endpoints we don't implement." echo "Closed issue #$ISSUE_NUM" fi - From e5d566ffde98c78ebf6143accfabeb29f850a1c5 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 17:33:07 -0500 Subject: [PATCH 12/29] remove test cron --- .github/workflows/check-endpoints.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 89cefed..d912ff8 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -14,9 +14,6 @@ on: # paths: # - ".github/workflows/check-endpoints.yml" # - "check-endpoints.py" - # Daily run for testing — ~6 PM EST (23:00 UTC). During EDT it runs ~7 PM local. - schedule: - - cron: "0 23 * * *" # Weekly (e.g. for production): "0 3 * * 1" = Monday 03:00 UTC # schedule: # - cron: "0 3 * * 1" From f88440a78d8fb0f0fcdc200d29dc41ca2732bfac Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 17:39:33 -0500 Subject: [PATCH 13/29] cleanup --- .github/workflows/check-endpoints.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index d912ff8..b934861 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -9,11 +9,6 @@ name: Check endpoints (spec vs implementation) on: workflow_dispatch: {} - # push: - # branches: ["check-endpoints-testing"] - # paths: - # - ".github/workflows/check-endpoints.yml" - # - "check-endpoints.py" # Weekly (e.g. for production): "0 3 * * 1" = Monday 03:00 UTC # schedule: # - cron: "0 3 * * 1" From 2952c5621f10bcfcae4a8be41d6f0ae5e4b8e68a Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 17:42:26 -0500 Subject: [PATCH 14/29] delete sample output --- check-endpoints-output.md | 101 -------------------------------------- 1 file changed, 101 deletions(-) delete mode 100644 check-endpoints-output.md diff --git a/check-endpoints-output.md b/check-endpoints-output.md deleted file mode 100644 index 8edbae7..0000000 --- a/check-endpoints-output.md +++ /dev/null @@ -1,101 +0,0 @@ -# Old output: - -Missing endpoints: - -/api/board/game/{gameId}/claim-draw -/api/bot/game/{gameId}/claim-draw -/api/bot/game/{gameId}/claim-victory -/api/broadcast/round/{broadcastRoundId}/reset -/api/bulk-pairing/{id}/games -/api/challenge/{challengeId}/show -/api/fide/player/{playerId}/ratings -/api/games/export/bookmarks -/api/puzzle/batch/{angle} -/api/puzzle/replay/{days}/{theme} -/api/racer/{id} -/api/study/by/{username}/export.pgn -/api/study/{studyId}/{chapterId}/tags -/api/user/{username}/note -/api/user/{username}/tournament/played -/broadcast/{broadcastTournamentId}/players -/broadcast/{broadcastTournamentId}/players/{playerId} -/broadcast/{broadcastTournamentId}/teams/standings -/oauth -https://tablebase.lichess.ovh/antichess -https://tablebase.lichess.ovh/atomic -https://tablebase.lichess.ovh/standard - -(those tablebase endpoints are false positives, but the script isn't correctly filtering them out) - -# New output: - -Exit codes: 0 = no missing; 1 = has missing endpoints or params; 2 = error (e.g. spec file not found). - -Missing (path, operation): - - /api/board/game/{gameId}/claim-draw POST - /api/bot/game/{gameId}/chat GET - /api/bot/game/{gameId}/claim-draw POST - /api/bot/game/{gameId}/claim-victory POST - /api/broadcast/round/{broadcastRoundId}/reset POST - /api/broadcast/{broadcastTournamentSlug}/{broadcastRoundSlug}/{broadcastRoundId} GET - /api/bulk-pairing/{id} GET - /api/bulk-pairing/{id}/games GET - /api/challenge/{challengeId}/show GET - /api/fide/player/{playerId}/ratings GET - /api/games/export/bookmarks GET - /api/puzzle/batch/{angle} GET - /api/puzzle/batch/{angle} POST - /api/puzzle/replay/{days}/{theme} GET - /api/racer/{id} GET - /api/study/by/{username}/export.pgn GET - /api/study/{studyId}/{chapterId} DELETE - /api/study/{studyId}/{chapterId}/tags POST - /api/token DELETE - /api/token POST - /api/tournament/{id} POST - /api/user/{username}/note GET - /api/user/{username}/note POST - /api/user/{username}/tournament/played GET - /broadcast/{broadcastTournamentId}/players GET - /broadcast/{broadcastTournamentId}/players/{playerId} GET - /broadcast/{broadcastTournamentId}/teams/standings GET - -Missing query params (implemented endpoints): - - /api/broadcast GET missing params: ['html'] (berserk/clients/broadcasts.py: Broadcasts.get_official) - /api/games/export/_ids POST missing params: ['accuracy', 'division', 'literate', 'pgnInJson'] (berserk/clients/games.py: Games.export_multi) - /api/games/user/{username} GET missing params: ['accuracy', 'division', 'lastFen', 'withBookmarked'] (berserk/clients/games.py: Games.export_by_player) - /api/puzzle/activity GET missing params: ['since'] (berserk/clients/puzzles.py: Puzzles.get_puzzle_activity) - /api/puzzle/next GET missing params: ['color'] (berserk/clients/puzzles.py: Puzzles.get_next) - /api/swiss/{id}/games GET missing params: ['accuracy', 'division', 'moves', 'player'] (berserk/clients/tournaments.py: Tournaments.export_swiss_games) - /api/team/{teamId}/arena GET missing params: ['createdBy', 'name', 'status'] (berserk/clients/tournaments.py: Tournaments.arenas_by_team) - /api/team/{teamId}/swiss GET missing params: ['createdBy', 'name', 'status'] (berserk/clients/tournaments.py: Tournaments.swiss_by_team) - /api/tournament/{id}/games GET missing params: ['accuracy', 'division', 'pgnInJson', 'player'] (berserk/clients/tournaments.py: Tournaments.export_arena_games) - /api/user/{username}/current-game GET missing params: ['accuracy', 'division'] (berserk/clients/games.py: Games.export_ongoing_by_player) - /api/user/{username}/tournament/created GET missing params: ['status'] (berserk/clients/tournaments.py: Tournaments.tournaments_by_user) - /game/export/{gameId} GET missing params: ['accuracy', 'division', 'withBookmarked'] (berserk/clients/games.py: Games.export) - https://explorer.lichess.ovh/player GET missing params: ['modes'] (berserk/clients/opening_explorer.py: OpeningExplorer.stream_player_games) - - JSON output (with `--json`; see exit codes above): - - { - "missing_endpoints": [ - { - "path": "/api/board/game/{gameId}/claim-draw", - "operation": "POST" - }, - ... - ], - "missing_params": [ - { - "path": "/api/broadcast", - "operation": "GET", - "params": [ - "html" - ], - "method": "berserk/clients/broadcasts.py: Broadcasts.get_official" - }, - ... - ] -} \ No newline at end of file From 5ccb198db87870ea97514a16affbe3d13c5da311 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Thu, 26 Feb 2026 17:53:14 -0500 Subject: [PATCH 15/29] commet in cron --- .github/workflows/check-endpoints.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index b934861..64350c8 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -10,8 +10,8 @@ name: Check endpoints (spec vs implementation) on: workflow_dispatch: {} # Weekly (e.g. for production): "0 3 * * 1" = Monday 03:00 UTC - # schedule: - # - cron: "0 3 * * 1" + schedule: + - cron: "0 3 * * 1" jobs: check-endpoints: From 5ef5b37c669e7ab3863fa3ba38a26c17850224cf Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 11:17:05 -0500 Subject: [PATCH 16/29] fix close-issue logic --- .github/workflows/check-endpoints.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 64350c8..9738314 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -111,8 +111,21 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + python3 - << 'PY' + intro = """This issue is automatically updated by the [check-endpoints](.github/workflows/check-endpoints.yml) workflow. Manual edits to the list below will be overwritten. For historical context and implementation notes, see issue 6. + + *Note: The text above and the list below are for testing only (e.g. when this workflow runs on a fork).* + + --- + + No missing endpoints or params as of this run. This issue will be reopened when the spec gains new endpoints or params we don't implement. + """ + with open("body.md", "w") as f: + f.write(intro) + PY ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) if [ -n "$ISSUE_NUM" ]; then - gh issue close "$ISSUE_NUM" --comment "No missing endpoints or params as of this run. This issue will be reopened when the spec gains new endpoints we don't implement." + gh issue edit "$ISSUE_NUM" --body-file body.md + gh issue close "$ISSUE_NUM" --comment "No missing endpoints or params as of this run. This issue will be reopened when the spec gains new endpoints or params we don't implement." echo "Closed issue #$ISSUE_NUM" fi From f38f14a15777d886f1d08f0c13f60cfd78d18570 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 11:33:08 -0500 Subject: [PATCH 17/29] fix exit code handling --- .github/workflows/check-endpoints.yml | 80 ++++++++++++--------------- check-endpoints.py | 48 ++++++++-------- 2 files changed, 60 insertions(+), 68 deletions(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 9738314..ba14ee8 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -38,29 +38,23 @@ jobs: curl -fsSL -o spec/openapi.yaml "https://lichess.org/api/openapi.yaml" - name: Run check-endpoints - id: check run: | - set +e - python check-endpoints.py --json spec/openapi.yaml > output.json 2>stderr.txt - EXIT=$? - set -e - echo "exit_code=$EXIT" >> "$GITHUB_OUTPUT" - # Exit 0 so this step is green; exit_code in output drives later steps. Only exit 2 is a real failure. - [ "$EXIT" = "2" ] && exit 2 || exit 0 + python check-endpoints.py --json spec/openapi.yaml > output.json 2>stderr.txt || { cat stderr.txt; exit 1; } - - name: Fail on script error (exit 2) - if: steps.check.outputs.exit_code == '2' - run: | - echo "check-endpoints.py failed (e.g. spec not found). stderr:" - cat stderr.txt - exit 1 - - - name: Build issue body and create or update issue (missing endpoints) - if: steps.check.outputs.exit_code == '1' + - name: Update issue from check result env: GH_TOKEN: ${{ github.token }} run: | - BODY=$(python3 - << 'PY' + HAS_MISSING=$(python3 -c " + import json + with open('output.json') as f: + data = json.load(f) + me = data.get('missing_endpoints') or [] + mp = data.get('missing_params') or [] + print('true' if (me or mp) else 'false') + ") + if [ "$HAS_MISSING" = "true" ]; then + BODY=$(python3 - << 'PY' import json import sys with open("output.json") as f: @@ -87,31 +81,24 @@ jobs: sys.stdout.write("".join(parts)) PY ) - echo "$BODY" > body.md - # Find open issue with our title - ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) - if [ -n "$ISSUE_NUM" ]; then - gh issue edit "$ISSUE_NUM" --body-file body.md - echo "Updated issue #$ISSUE_NUM" - else - # Reopen if closed: find any issue with that title - OLD_NUM=$(gh issue list --repo "${{ github.repository }}" --state all --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) - if [ -n "$OLD_NUM" ]; then - gh issue reopen "$OLD_NUM" - gh issue edit "$OLD_NUM" --body-file body.md - echo "Reopened and updated issue #$OLD_NUM" + echo "$BODY" > body.md + ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) + if [ -n "$ISSUE_NUM" ]; then + gh issue edit "$ISSUE_NUM" --body-file body.md + echo "Updated issue #$ISSUE_NUM" else - gh issue create --title "Spec vs implementation: missing endpoints or params (auto-updated)" --body-file body.md - echo "Created new issue" + OLD_NUM=$(gh issue list --repo "${{ github.repository }}" --state all --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) + if [ -n "$OLD_NUM" ]; then + gh issue reopen "$OLD_NUM" + gh issue edit "$OLD_NUM" --body-file body.md + echo "Reopened and updated issue #$OLD_NUM" + else + gh issue create --title "Spec vs implementation: missing endpoints or params (auto-updated)" --body-file body.md + echo "Created new issue" + fi fi - fi - - - name: Close issue if no missing endpoints - if: steps.check.outputs.exit_code == '0' - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 - << 'PY' + else + python3 - << 'PY' intro = """This issue is automatically updated by the [check-endpoints](.github/workflows/check-endpoints.yml) workflow. Manual edits to the list below will be overwritten. For historical context and implementation notes, see issue 6. *Note: The text above and the list below are for testing only (e.g. when this workflow runs on a fork).* @@ -123,9 +110,10 @@ jobs: with open("body.md", "w") as f: f.write(intro) PY - ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) - if [ -n "$ISSUE_NUM" ]; then - gh issue edit "$ISSUE_NUM" --body-file body.md - gh issue close "$ISSUE_NUM" --comment "No missing endpoints or params as of this run. This issue will be reopened when the spec gains new endpoints or params we don't implement." - echo "Closed issue #$ISSUE_NUM" + ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) + if [ -n "$ISSUE_NUM" ]; then + gh issue edit "$ISSUE_NUM" --body-file body.md + gh issue close "$ISSUE_NUM" --comment "No missing endpoints or params as of this run. This issue will be reopened when the spec gains new endpoints or params we don't implement." + echo "Closed issue #$ISSUE_NUM" + fi fi diff --git a/check-endpoints.py b/check-endpoints.py index 4ce2a41..97e7275 100644 --- a/check-endpoints.py +++ b/check-endpoints.py @@ -6,9 +6,10 @@ as a variable or as a literal/f-string. For implemented endpoints, also checks that query params passed to the request match the spec. -Exit codes: 0 = no missing endpoints or params; 1 = has missing (for CI/alerting); -2 = error (bad args, spec file not found, etc.). With --json, only JSON is printed -to stdout for machine consumption; errors always go to stderr. +Exit codes: 0 = success (run completed; check JSON or human output for missing/not); +non-zero = error (bad args, spec not found, exception). With --json, only JSON is +printed to stdout for CI; empty missing_endpoints and missing_params means nothing missing. +Errors always go to stderr. """ from __future__ import annotations @@ -17,14 +18,12 @@ import json import re import sys +import traceback from pathlib import Path import yaml -# Exit codes: 0 = no missing, 1 = has missing (endpoints or params), 2 = error (usage/file) -EXIT_OK = 0 -EXIT_HAS_MISSING = 1 -EXIT_ERROR = 2 +EXIT_ERROR = 1 # Paths that appear in spec but are implemented dynamically (e.g. tablebase /{variant}) FALSE_POSITIVES = {"/standard", "/atomic", "/antichess", "/oauth"} @@ -377,23 +376,28 @@ def _format_method(file: str, class_name: str, method_name: str) -> str: } print(json.dumps(out, indent=2)) else: - if missing: - print("\nMissing (path, operation):\n") - for path, op in sorted(missing): - print(f" {path} {op.upper()}") + if not has_missing: + print("Nothing missing") else: - print("No missing endpoints") - if missing_params_list: - print("\nMissing query params (implemented endpoints):\n") - for path, op, params, method_str in sorted( - missing_params_list, key=lambda x: (x[0], x[1]) - ): - print( - f" {path} {op.upper()} missing params: {sorted(params)} ({method_str})" - ) + if missing: + print("\nMissing (path, operation):\n") + for path, op in sorted(missing): + print(f" {path} {op.upper()}") + if missing_params_list: + print("\nMissing query params (implemented endpoints):\n") + for path, op, params, method_str in sorted( + missing_params_list, key=lambda x: (x[0], x[1]) + ): + print( + f" {path} {op.upper()} missing params: {sorted(params)} ({method_str})" + ) - sys.exit(EXIT_HAS_MISSING if has_missing else EXIT_OK) + sys.exit(0) if __name__ == "__main__": - main() + try: + main() + except Exception: + traceback.print_exc(file=sys.stderr) + sys.exit(EXIT_ERROR) From b891c93486706ff4ef2e2a828f20be92e2e10016 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 11:58:23 -0500 Subject: [PATCH 18/29] add initial check-endpoint script tests --- dev_tests/test_check_endpoints.py | 98 +++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 dev_tests/test_check_endpoints.py diff --git a/dev_tests/test_check_endpoints.py b/dev_tests/test_check_endpoints.py new file mode 100644 index 0000000..fef4dbf --- /dev/null +++ b/dev_tests/test_check_endpoints.py @@ -0,0 +1,98 @@ +"""Unit tests for check-endpoints.py (dev tool). Not run by make test. + +Run manually: uv run pytest dev_tests/test_check_endpoints.py -v +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +# Load check-endpoints.py (hyphen in name) as a module +_ROOT = Path(__file__).resolve().parent.parent +_SPEC = importlib.util.spec_from_file_location( + "check_endpoints", _ROOT / "check-endpoints.py" +) +assert _SPEC is not None and _SPEC.loader is not None +check_endpoints = importlib.util.module_from_spec(_SPEC) +sys.modules["check_endpoints"] = check_endpoints +_SPEC.loader.exec_module(check_endpoints) + + +class TestNormalizePathTemplate: + def test_leading_slash_added(self): + assert check_endpoints.normalize_path_template("api/games") == "/api/games" + + def test_trailing_slash_removed(self): + assert check_endpoints.normalize_path_template("/api/games/") == "/api/games" + + def test_placeholder_collapsed(self): + assert check_endpoints.normalize_path_template("/api/game/{id}") == "/api/game/{}" + assert check_endpoints.normalize_path_template("/api/{gameId}/claim") == "/api/{}/claim" + + def test_query_stripped(self): + assert check_endpoints.normalize_path_template("/api?page=1") == "/api" + assert check_endpoints.normalize_path_template("/api?page=1&foo=bar") == "/api" + + def test_unchanged_when_already_normalized(self): + assert check_endpoints.normalize_path_template("/api/games") == "/api/games" + + +class TestQueryParamKeysFromPath: + def test_no_query_returns_empty(self): + assert check_endpoints.query_param_keys_from_path("/api") == set() + assert check_endpoints.query_param_keys_from_path(None) == set() + assert check_endpoints.query_param_keys_from_path("") == set() + + def test_single_param(self): + assert check_endpoints.query_param_keys_from_path("/api?page=1") == {"page"} + + def test_multiple_params(self): + assert check_endpoints.query_param_keys_from_path("/api?a=1&b=2") == {"a", "b"} + + +class TestSpecQueryParams: + def test_empty_path_item(self): + assert check_endpoints.spec_query_params({}, "get") == set() + + def test_operation_level_query_params(self): + path_item = { + "get": { + "parameters": [ + {"name": "page", "in": "query"}, + {"name": "Accept", "in": "header"}, + ] + } + } + assert check_endpoints.spec_query_params(path_item, "get") == {"page"} + + def test_path_level_and_operation_merged(self): + path_item = { + "parameters": [{"name": "common", "in": "query"}], + "get": {"parameters": [{"name": "page", "in": "query"}]}, + } + assert check_endpoints.spec_query_params(path_item, "get") == {"common", "page"} + + +class TestSpecPathOperations: + def test_yields_path_and_operation(self): + spec = { + "paths": { + "/api/games": {"get": {}}, + "/api/user": {"get": {}, "post": {}}, + } + } + out = list(check_endpoints.spec_path_operations(spec)) + assert ("/api/games", spec["paths"]["/api/games"], "get") in out + assert ("/api/user", spec["paths"]["/api/user"], "get") in out + assert ("/api/user", spec["paths"]["/api/user"], "post") in out + assert len(out) == 3 + + +class TestFalsePositives: + def test_expected_paths_excluded(self): + assert "/oauth" in check_endpoints.FALSE_POSITIVES + assert "/standard" in check_endpoints.FALSE_POSITIVES + assert "/atomic" in check_endpoints.FALSE_POSITIVES + assert "/antichess" in check_endpoints.FALSE_POSITIVES From ec33e59e59df56ef64d90743d979a97330972f90 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 12:11:26 -0500 Subject: [PATCH 19/29] add missing-params tests, allow client-dir option for check-endpoints.py --- check-endpoints.py | 14 ++- dev_tests/fixtures/fake_clients/minimal.py | 9 ++ dev_tests/fixtures/minimal_spec.yaml | 4 + dev_tests/fixtures/spec_with_extra_param.yaml | 10 ++ dev_tests/test_check_endpoints.py | 97 ++++++++++++++++++- 5 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 dev_tests/fixtures/fake_clients/minimal.py create mode 100644 dev_tests/fixtures/minimal_spec.yaml create mode 100644 dev_tests/fixtures/spec_with_extra_param.yaml diff --git a/check-endpoints.py b/check-endpoints.py index 97e7275..ea05021 100644 --- a/check-endpoints.py +++ b/check-endpoints.py @@ -284,11 +284,20 @@ def main() -> None: json_output = "--json" in argv args = [a for a in argv if a != "--json"] + clients_dir = Path("berserk/clients") + if "--clients-dir" in args: + idx = args.index("--clients-dir") + if idx + 1 >= len(args): + print("Usage: --clients-dir requires a path", file=sys.stderr) + sys.exit(EXIT_ERROR) + clients_dir = Path(args[idx + 1]) + args = [a for i, a in enumerate(args) if i != idx and i != idx + 1] + if len(args) != 1: spec_path = Path("../api/doc/specs/lichess-api.yaml") if not spec_path.is_file(): print( - "Usage: check-endpoints.py [--json] ", + "Usage: check-endpoints.py [--json] [--clients-dir DIR] ", file=sys.stderr, ) sys.exit(EXIT_ERROR) @@ -305,9 +314,8 @@ def main() -> None: print(f"Error reading spec: {e}", file=sys.stderr) sys.exit(EXIT_ERROR) - clients_dir = Path("berserk/clients") if not clients_dir.is_dir(): - print("Run from repo root (berserk/clients must exist).", file=sys.stderr) + print(f"Clients dir not found: {clients_dir}", file=sys.stderr) sys.exit(EXIT_ERROR) implemented, implemented_params, implemented_method_info = ( diff --git a/dev_tests/fixtures/fake_clients/minimal.py b/dev_tests/fixtures/fake_clients/minimal.py new file mode 100644 index 0000000..07d4b59 --- /dev/null +++ b/dev_tests/fixtures/fake_clients/minimal.py @@ -0,0 +1,9 @@ +# Only for dev_tests/test_check_endpoints.py. Implements GET /api/dev-tests/fixture +# with param "a"; spec adds "b" so script reports one missing_params entry. +# Script only parses AST; this file is never imported. + +class FixtureClient: + def get_fixture(self, a): + path = "/api/dev-tests/fixture" + params = {"a": a} + return self._r.get(path, params=params) diff --git a/dev_tests/fixtures/minimal_spec.yaml b/dev_tests/fixtures/minimal_spec.yaml new file mode 100644 index 0000000..f11c2a1 --- /dev/null +++ b/dev_tests/fixtures/minimal_spec.yaml @@ -0,0 +1,4 @@ +# Minimal OpenAPI-like spec for check-endpoints tests. +paths: + /api/minimal-test: + get: {} diff --git a/dev_tests/fixtures/spec_with_extra_param.yaml b/dev_tests/fixtures/spec_with_extra_param.yaml new file mode 100644 index 0000000..36c01e3 --- /dev/null +++ b/dev_tests/fixtures/spec_with_extra_param.yaml @@ -0,0 +1,10 @@ +# Used with --clients-dir dev_tests/fixtures/fake_clients. Fixture implements +# GET /api/dev-tests/fixture with param "a"; this spec adds "b" so missing_params has one entry. +paths: + /api/dev-tests/fixture: + get: + parameters: + - name: a + in: query + - name: b + in: query diff --git a/dev_tests/test_check_endpoints.py b/dev_tests/test_check_endpoints.py index fef4dbf..0e799a3 100644 --- a/dev_tests/test_check_endpoints.py +++ b/dev_tests/test_check_endpoints.py @@ -6,6 +6,8 @@ from __future__ import annotations import importlib.util +import json +import subprocess import sys from pathlib import Path @@ -28,8 +30,13 @@ def test_trailing_slash_removed(self): assert check_endpoints.normalize_path_template("/api/games/") == "/api/games" def test_placeholder_collapsed(self): - assert check_endpoints.normalize_path_template("/api/game/{id}") == "/api/game/{}" - assert check_endpoints.normalize_path_template("/api/{gameId}/claim") == "/api/{}/claim" + assert ( + check_endpoints.normalize_path_template("/api/game/{id}") == "/api/game/{}" + ) + assert ( + check_endpoints.normalize_path_template("/api/{gameId}/claim") + == "/api/{}/claim" + ) def test_query_stripped(self): assert check_endpoints.normalize_path_template("/api?page=1") == "/api" @@ -96,3 +103,89 @@ def test_expected_paths_excluded(self): assert "/standard" in check_endpoints.FALSE_POSITIVES assert "/atomic" in check_endpoints.FALSE_POSITIVES assert "/antichess" in check_endpoints.FALSE_POSITIVES + + +def _run_script(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + """Run check-endpoints.py; cwd defaults to repo root.""" + cmd = [sys.executable, str(_ROOT / "check-endpoints.py"), *args] + return subprocess.run( + cmd, + cwd=cwd or _ROOT, + capture_output=True, + text=True, + ) + + +_FIXTURES = _ROOT / "dev_tests" / "fixtures" +_FAKE_CLIENTS = _FIXTURES / "fake_clients" + + +class TestExitCode: + """Script exit code: 0 on success, non-zero on error.""" + + def test_no_args_exits_non_zero(self): + result = _run_script() + assert result.returncode != 0 + assert "Usage" in result.stderr or "Spec file not found" in result.stderr + + def test_nonexistent_spec_exits_non_zero(self): + result = _run_script("nonexistent.yaml") + assert result.returncode != 0 + assert "not found" in result.stderr or "Spec file" in result.stderr + + def test_valid_spec_exits_zero(self): + spec_path = _ROOT / "dev_tests" / "fixtures" / "minimal_spec.yaml" + result = _run_script("--json", str(spec_path)) + assert result.returncode == 0, result.stderr + + +class TestJsonOutput: + """With --json, stdout is valid JSON with expected keys and types.""" + + def test_json_has_required_keys(self): + spec_path = _ROOT / "dev_tests" / "fixtures" / "minimal_spec.yaml" + result = _run_script("--json", str(spec_path)) + result.check_returncode() + data = json.loads(result.stdout) + assert "missing_endpoints" in data + assert "missing_params" in data + + def test_missing_endpoints_and_params_are_lists(self): + spec_path = _ROOT / "dev_tests" / "fixtures" / "minimal_spec.yaml" + result = _run_script("--json", str(spec_path)) + result.check_returncode() + data = json.loads(result.stdout) + assert isinstance(data["missing_endpoints"], list) + assert isinstance(data["missing_params"], list) + + def test_missing_endpoint_item_has_path_and_operation(self): + """When there are missing endpoints, each item has path and operation.""" + spec_path = _ROOT / "dev_tests" / "fixtures" / "minimal_spec.yaml" + result = _run_script("--json", str(spec_path)) + result.check_returncode() + data = json.loads(result.stdout) + # Minimal spec has /api/minimal-test get which we don't implement + assert len(data["missing_endpoints"]) >= 1 + item = data["missing_endpoints"][0] + assert "path" in item + assert "operation" in item + + def test_missing_params_item_has_path_operation_params_and_method(self): + """Fixture client implements one param; spec adds another → one missing_params entry with correct shape.""" + spec_path = _FIXTURES / "spec_with_extra_param.yaml" + result = _run_script( + "--json", + "--clients-dir", + str(_FAKE_CLIENTS), + str(spec_path), + ) + result.check_returncode() + data = json.loads(result.stdout) + assert len(data["missing_params"]) == 1, data + item = data["missing_params"][0] + assert item["path"] == "/api/dev-tests/fixture" + assert item["operation"] == "GET" + assert "params" in item + assert "method" in item + assert isinstance(item["params"], list) + assert item["params"] == ["b"] From c4eb6814aad8400431746e70251d5781132a67dc Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 12:14:14 -0500 Subject: [PATCH 20/29] working missing-params tests --- dev_tests/fixtures/fake_clients/minimal.py | 1 + dev_tests/test_check_endpoints.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dev_tests/fixtures/fake_clients/minimal.py b/dev_tests/fixtures/fake_clients/minimal.py index 07d4b59..59a6042 100644 --- a/dev_tests/fixtures/fake_clients/minimal.py +++ b/dev_tests/fixtures/fake_clients/minimal.py @@ -2,6 +2,7 @@ # with param "a"; spec adds "b" so script reports one missing_params entry. # Script only parses AST; this file is never imported. + class FixtureClient: def get_fixture(self, a): path = "/api/dev-tests/fixture" diff --git a/dev_tests/test_check_endpoints.py b/dev_tests/test_check_endpoints.py index 0e799a3..a8f643f 100644 --- a/dev_tests/test_check_endpoints.py +++ b/dev_tests/test_check_endpoints.py @@ -105,7 +105,9 @@ def test_expected_paths_excluded(self): assert "/antichess" in check_endpoints.FALSE_POSITIVES -def _run_script(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: +def _run_script( + *args: str, cwd: Path | None = None +) -> subprocess.CompletedProcess[str]: """Run check-endpoints.py; cwd defaults to repo root.""" cmd = [sys.executable, str(_ROOT / "check-endpoints.py"), *args] return subprocess.run( From 4b8c098ee8773a652f1b0e569e31872b37c02784 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 12:17:05 -0500 Subject: [PATCH 21/29] add tests for clients-dir, missing-endpoints, matching spec/client, false-positives, human-readable output --- dev_tests/fixtures/spec_exact_match.yaml | 8 +++ .../fixtures/spec_false_positive_only.yaml | 4 ++ .../fixtures/spec_one_missing_endpoint.yaml | 7 +++ dev_tests/test_check_endpoints.py | 54 +++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 dev_tests/fixtures/spec_exact_match.yaml create mode 100644 dev_tests/fixtures/spec_false_positive_only.yaml create mode 100644 dev_tests/fixtures/spec_one_missing_endpoint.yaml diff --git a/dev_tests/fixtures/spec_exact_match.yaml b/dev_tests/fixtures/spec_exact_match.yaml new file mode 100644 index 0000000..39216c5 --- /dev/null +++ b/dev_tests/fixtures/spec_exact_match.yaml @@ -0,0 +1,8 @@ +# Used with --clients-dir fake_clients. Fixture implements GET /api/dev-tests/fixture with param "a". +# This spec matches exactly → no missing endpoints, no missing params. +paths: + /api/dev-tests/fixture: + get: + parameters: + - name: a + in: query diff --git a/dev_tests/fixtures/spec_false_positive_only.yaml b/dev_tests/fixtures/spec_false_positive_only.yaml new file mode 100644 index 0000000..6e54f5c --- /dev/null +++ b/dev_tests/fixtures/spec_false_positive_only.yaml @@ -0,0 +1,4 @@ +# Spec with only a path in FALSE_POSITIVES (/oauth). Script should not report it as missing. +paths: + /oauth: + get: {} diff --git a/dev_tests/fixtures/spec_one_missing_endpoint.yaml b/dev_tests/fixtures/spec_one_missing_endpoint.yaml new file mode 100644 index 0000000..1591716 --- /dev/null +++ b/dev_tests/fixtures/spec_one_missing_endpoint.yaml @@ -0,0 +1,7 @@ +# Used with --clients-dir fake_clients. Fixture implements only /api/dev-tests/fixture. +# So /api/dev-tests/other is missing. +paths: + /api/dev-tests/fixture: + get: {} + /api/dev-tests/other: + get: {} diff --git a/dev_tests/test_check_endpoints.py b/dev_tests/test_check_endpoints.py index a8f643f..bd46ccc 100644 --- a/dev_tests/test_check_endpoints.py +++ b/dev_tests/test_check_endpoints.py @@ -140,6 +140,12 @@ def test_valid_spec_exits_zero(self): result = _run_script("--json", str(spec_path)) assert result.returncode == 0, result.stderr + def test_clients_dir_nonexistent_exits_non_zero(self): + spec_path = _FIXTURES / "minimal_spec.yaml" + result = _run_script("--json", "--clients-dir", "/nonexistent", str(spec_path)) + assert result.returncode != 0 + assert "not found" in result.stderr or "Clients dir" in result.stderr + class TestJsonOutput: """With --json, stdout is valid JSON with expected keys and types.""" @@ -191,3 +197,51 @@ def test_missing_params_item_has_path_operation_params_and_method(self): assert "method" in item assert isinstance(item["params"], list) assert item["params"] == ["b"] + + def test_missing_endpoint_reported_when_not_implemented(self): + """Fixture implements one path; spec has two. Unimplemented path appears in missing_endpoints.""" + spec_path = _FIXTURES / "spec_one_missing_endpoint.yaml" + result = _run_script( + "--json", + "--clients-dir", + str(_FAKE_CLIENTS), + str(spec_path), + ) + result.check_returncode() + data = json.loads(result.stdout) + assert len(data["missing_endpoints"]) == 1, data + assert data["missing_endpoints"][0]["path"] == "/api/dev-tests/other" + assert data["missing_endpoints"][0]["operation"] == "GET" + + def test_no_missing_when_spec_and_client_match(self): + """Fixture and spec match exactly → empty missing_endpoints and missing_params.""" + spec_path = _FIXTURES / "spec_exact_match.yaml" + result = _run_script( + "--json", + "--clients-dir", + str(_FAKE_CLIENTS), + str(spec_path), + ) + result.check_returncode() + data = json.loads(result.stdout) + assert data["missing_endpoints"] == [], data + assert data["missing_params"] == [], data + + def test_false_positive_not_reported_as_missing(self): + """Spec with only /oauth (in FALSE_POSITIVES) → missing_endpoints is empty.""" + spec_path = _FIXTURES / "spec_false_positive_only.yaml" + result = _run_script("--json", str(spec_path)) + result.check_returncode() + data = json.loads(result.stdout) + assert data["missing_endpoints"] == [], data + + +class TestHumanOutput: + """Without --json, human-readable output.""" + + def test_nothing_missing_printed_when_all_match(self): + """Exact match → stdout contains 'Nothing missing'.""" + spec_path = _FIXTURES / "spec_exact_match.yaml" + result = _run_script("--clients-dir", str(_FAKE_CLIENTS), str(spec_path)) + result.check_returncode() + assert "Nothing missing" in result.stdout From f0cb64dc4457c9c562bf1caeb988bbb0fb8ef0ce Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 12:25:27 -0500 Subject: [PATCH 22/29] add tests for other code paths --- .../fixtures/fake_clients/fstring_client.py | 7 +++ .../fake_clients/params_variable_client.py | 8 ++++ .../fake_clients/path_keyword_client.py | 6 +++ .../fake_clients/request_method_client.py | 6 +++ dev_tests/fixtures/spec_fstring.yaml | 4 ++ dev_tests/fixtures/spec_params_variable.yaml | 10 ++++ dev_tests/fixtures/spec_path_keyword.yaml | 4 ++ dev_tests/fixtures/spec_request_method.yaml | 4 ++ dev_tests/test_check_endpoints.py | 48 +++++++++++++++++++ 9 files changed, 97 insertions(+) create mode 100644 dev_tests/fixtures/fake_clients/fstring_client.py create mode 100644 dev_tests/fixtures/fake_clients/params_variable_client.py create mode 100644 dev_tests/fixtures/fake_clients/path_keyword_client.py create mode 100644 dev_tests/fixtures/fake_clients/request_method_client.py create mode 100644 dev_tests/fixtures/spec_fstring.yaml create mode 100644 dev_tests/fixtures/spec_params_variable.yaml create mode 100644 dev_tests/fixtures/spec_path_keyword.yaml create mode 100644 dev_tests/fixtures/spec_request_method.yaml diff --git a/dev_tests/fixtures/fake_clients/fstring_client.py b/dev_tests/fixtures/fake_clients/fstring_client.py new file mode 100644 index 0000000..6305a9e --- /dev/null +++ b/dev_tests/fixtures/fake_clients/fstring_client.py @@ -0,0 +1,7 @@ +# Fixture for integration test: visitor discovers f-string path (JoinedStr). +# Script normalizes to /api/dev-tests/fstring/{}. + +class FstringClient: + def get_with_fstring(self, id): + path = f"/api/dev-tests/fstring/{id}" + return self._r.get(path) diff --git a/dev_tests/fixtures/fake_clients/params_variable_client.py b/dev_tests/fixtures/fake_clients/params_variable_client.py new file mode 100644 index 0000000..044f587 --- /dev/null +++ b/dev_tests/fixtures/fake_clients/params_variable_client.py @@ -0,0 +1,8 @@ +# Fixture for integration test: visitor discovers params from variable (params_scope). +# Assign params = {"q": q}, then pass params=params. Spec adds "r" so we get one missing param. + +class ParamsVariableClient: + def get_with_params_var(self, q): + path = "/api/dev-tests/params-var" + params = {"q": q} + return self._r.get(path, params=params) diff --git a/dev_tests/fixtures/fake_clients/path_keyword_client.py b/dev_tests/fixtures/fake_clients/path_keyword_client.py new file mode 100644 index 0000000..5635c18 --- /dev/null +++ b/dev_tests/fixtures/fake_clients/path_keyword_client.py @@ -0,0 +1,6 @@ +# Fixture for integration test: visitor discovers path passed as keyword (path=path). + +class PathKeywordClient: + def get_with_path_keyword(self): + path = "/api/dev-tests/path-keyword" + return self._r.get(path=path) diff --git a/dev_tests/fixtures/fake_clients/request_method_client.py b/dev_tests/fixtures/fake_clients/request_method_client.py new file mode 100644 index 0000000..05eff24 --- /dev/null +++ b/dev_tests/fixtures/fake_clients/request_method_client.py @@ -0,0 +1,6 @@ +# Fixture for integration test: visitor discovers self._r.request(method=..., path=...). + +class RequestMethodClient: + def get_via_request(self): + path = "/api/dev-tests/request-method" + return self._r.request(method="GET", path=path) diff --git a/dev_tests/fixtures/spec_fstring.yaml b/dev_tests/fixtures/spec_fstring.yaml new file mode 100644 index 0000000..ad90914 --- /dev/null +++ b/dev_tests/fixtures/spec_fstring.yaml @@ -0,0 +1,4 @@ +# F-string path: fixture fstring_client.py implements GET /api/dev-tests/fstring/{} (normalized). +paths: + /api/dev-tests/fstring/{}: + get: {} diff --git a/dev_tests/fixtures/spec_params_variable.yaml b/dev_tests/fixtures/spec_params_variable.yaml new file mode 100644 index 0000000..30d490a --- /dev/null +++ b/dev_tests/fixtures/spec_params_variable.yaml @@ -0,0 +1,10 @@ +# Params from variable: fixture params_variable_client.py passes params=params with {"q": q}. +# Spec adds "r" so script reports one missing param. +paths: + /api/dev-tests/params-var: + get: + parameters: + - name: q + in: query + - name: r + in: query diff --git a/dev_tests/fixtures/spec_path_keyword.yaml b/dev_tests/fixtures/spec_path_keyword.yaml new file mode 100644 index 0000000..8864d6a --- /dev/null +++ b/dev_tests/fixtures/spec_path_keyword.yaml @@ -0,0 +1,4 @@ +# path= keyword: fixture path_keyword_client.py implements GET with path=path. +paths: + /api/dev-tests/path-keyword: + get: {} diff --git a/dev_tests/fixtures/spec_request_method.yaml b/dev_tests/fixtures/spec_request_method.yaml new file mode 100644 index 0000000..f0566ce --- /dev/null +++ b/dev_tests/fixtures/spec_request_method.yaml @@ -0,0 +1,4 @@ +# request(method=..., path=...): fixture request_method_client.py implements this path. +paths: + /api/dev-tests/request-method: + get: {} diff --git a/dev_tests/test_check_endpoints.py b/dev_tests/test_check_endpoints.py index bd46ccc..121d932 100644 --- a/dev_tests/test_check_endpoints.py +++ b/dev_tests/test_check_endpoints.py @@ -236,6 +236,54 @@ def test_false_positive_not_reported_as_missing(self): assert data["missing_endpoints"] == [], data +class TestDiscoveryCodePaths: + """Integration tests: script discovers endpoints for real code patterns (f-string, request(), path=, params var).""" + + def test_fstring_path_discovered(self): + """Visitor resolves JoinedStr path (f\"/api/.../{id}\") → normalized /api/.../{}.""" + spec_path = _FIXTURES / "spec_fstring.yaml" + result = _run_script( + "--json", "--clients-dir", str(_FAKE_CLIENTS), str(spec_path) + ) + result.check_returncode() + data = json.loads(result.stdout) + assert data["missing_endpoints"] == [], data + + def test_request_method_path_discovered(self): + """Visitor finds self._r.request(method=\"GET\", path=path).""" + spec_path = _FIXTURES / "spec_request_method.yaml" + result = _run_script( + "--json", "--clients-dir", str(_FAKE_CLIENTS), str(spec_path) + ) + result.check_returncode() + data = json.loads(result.stdout) + assert data["missing_endpoints"] == [], data + + def test_path_keyword_discovered(self): + """Visitor finds path passed as keyword: self._r.get(path=path).""" + spec_path = _FIXTURES / "spec_path_keyword.yaml" + result = _run_script( + "--json", "--clients-dir", str(_FAKE_CLIENTS), str(spec_path) + ) + result.check_returncode() + data = json.loads(result.stdout) + assert data["missing_endpoints"] == [], data + + def test_params_from_variable_discovered(self): + """Visitor resolves params=params when params was assigned a dict earlier → missing param 'r' reported.""" + spec_path = _FIXTURES / "spec_params_variable.yaml" + result = _run_script( + "--json", "--clients-dir", str(_FAKE_CLIENTS), str(spec_path) + ) + result.check_returncode() + data = json.loads(result.stdout) + assert data["missing_endpoints"] == [], data + assert len(data["missing_params"]) == 1, data + item = data["missing_params"][0] + assert item["path"] == "/api/dev-tests/params-var" + assert item["params"] == ["r"] + + class TestHumanOutput: """Without --json, human-readable output.""" From 4a96eb10bd853230914312934d16ce3e81d2b39b Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 12:25:43 -0500 Subject: [PATCH 23/29] formatted --- dev_tests/fixtures/fake_clients/fstring_client.py | 1 + dev_tests/fixtures/fake_clients/params_variable_client.py | 1 + dev_tests/fixtures/fake_clients/path_keyword_client.py | 1 + dev_tests/fixtures/fake_clients/request_method_client.py | 1 + 4 files changed, 4 insertions(+) diff --git a/dev_tests/fixtures/fake_clients/fstring_client.py b/dev_tests/fixtures/fake_clients/fstring_client.py index 6305a9e..bcf19b2 100644 --- a/dev_tests/fixtures/fake_clients/fstring_client.py +++ b/dev_tests/fixtures/fake_clients/fstring_client.py @@ -1,6 +1,7 @@ # Fixture for integration test: visitor discovers f-string path (JoinedStr). # Script normalizes to /api/dev-tests/fstring/{}. + class FstringClient: def get_with_fstring(self, id): path = f"/api/dev-tests/fstring/{id}" diff --git a/dev_tests/fixtures/fake_clients/params_variable_client.py b/dev_tests/fixtures/fake_clients/params_variable_client.py index 044f587..fbb337b 100644 --- a/dev_tests/fixtures/fake_clients/params_variable_client.py +++ b/dev_tests/fixtures/fake_clients/params_variable_client.py @@ -1,6 +1,7 @@ # Fixture for integration test: visitor discovers params from variable (params_scope). # Assign params = {"q": q}, then pass params=params. Spec adds "r" so we get one missing param. + class ParamsVariableClient: def get_with_params_var(self, q): path = "/api/dev-tests/params-var" diff --git a/dev_tests/fixtures/fake_clients/path_keyword_client.py b/dev_tests/fixtures/fake_clients/path_keyword_client.py index 5635c18..6d09736 100644 --- a/dev_tests/fixtures/fake_clients/path_keyword_client.py +++ b/dev_tests/fixtures/fake_clients/path_keyword_client.py @@ -1,5 +1,6 @@ # Fixture for integration test: visitor discovers path passed as keyword (path=path). + class PathKeywordClient: def get_with_path_keyword(self): path = "/api/dev-tests/path-keyword" diff --git a/dev_tests/fixtures/fake_clients/request_method_client.py b/dev_tests/fixtures/fake_clients/request_method_client.py index 05eff24..584e324 100644 --- a/dev_tests/fixtures/fake_clients/request_method_client.py +++ b/dev_tests/fixtures/fake_clients/request_method_client.py @@ -1,5 +1,6 @@ # Fixture for integration test: visitor discovers self._r.request(method=..., path=...). + class RequestMethodClient: def get_via_request(self): path = "/api/dev-tests/request-method" From f38455cdf24adb855da9429bbb6e6e1eab384047 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 12:29:21 -0500 Subject: [PATCH 24/29] update CONTRIBUTING --- CONTRIBUTING.rst | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 57ed79e..de17dc3 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -24,7 +24,19 @@ Look through the GitHub issues for bugs. Implement Missing Endpoints ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -You can run the ``check-endpoints.py`` script (requires yaml to be installed, ``pip3 install pyyaml``) in the root of the project to get a list of endpoints that are still missing. +The ``check-endpoints.py`` script (in the project root) compares the client code in ``berserk/clients/`` to the Lichess API spec and reports missing endpoints and missing query parameters. It requires PyYAML (``pip install pyyaml`` or use the project env via ``uv run``). + +Run from the repo root with a path to the spec (e.g. a local OpenAPI YAML or the deployed spec):: + + uv run python check-endpoints.py path/to/openapi.yaml + +Use ``--json`` for machine-readable output (e.g. for CI). Use ``--clients-dir DIR`` to point at a different client tree (default is ``berserk/clients``). Exit 0 on success; non-zero on error (bad args, spec not found). When nothing is missing, both ``missing_endpoints`` and ``missing_params`` in the JSON are empty lists. + +**CI and auto-generated issue:** The workflow in ``.github/workflows/check-endpoints.yml`` runs on a schedule (and manually via workflow_dispatch). It fetches the latest spec from the Lichess API, runs the script, and creates or updates a single issue titled "Spec vs implementation: missing endpoints or params (auto-updated)" with the current list. When there are no missing items, it closes that issue (after updating its body). This issue is the live checklist; see the historical umbrella issue in the repo for context. + +**Tests for check-endpoints.py:** The script has its own tests in ``dev_tests/test_check_endpoints.py``. These are **not** run by ``make test`` (which only runs ``tests/``). To run them manually:: + + uv run pytest dev_tests/test_check_endpoints.py -v Write Documentation ~~~~~~~~~~~~~~~~~~~ From 2d4176b6a2eddffaf28857e5c1cf2b38a23e5c6c Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 12:38:32 -0500 Subject: [PATCH 25/29] show human-readable output in CI logs --- .github/workflows/check-endpoints.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index ba14ee8..229b6bc 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -39,6 +39,7 @@ jobs: - name: Run check-endpoints run: | + python check-endpoints.py spec/openapi.yaml python check-endpoints.py --json spec/openapi.yaml > output.json 2>stderr.txt || { cat stderr.txt; exit 1; } - name: Update issue from check result From cd7985055443704e0b64b54b042888cd555f04d1 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 12:39:55 -0500 Subject: [PATCH 26/29] add test trigger --- .github/workflows/check-endpoints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 229b6bc..8e5b2ba 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -12,6 +12,11 @@ on: # Weekly (e.g. for production): "0 3 * * 1" = Monday 03:00 UTC schedule: - cron: "0 3 * * 1" + push: + branches: ["feature/check-endpoints-ci"] + paths: + - ".github/workflows/check-endpoints.yml" + - "check-endpoints.py" jobs: check-endpoints: From 9ce233b90d437d389e742d71361757f6e1a9afca Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 13:41:07 -0500 Subject: [PATCH 27/29] log issue body not duplicate script-run output --- .github/workflows/check-endpoints.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index 8e5b2ba..c2da1c2 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -44,7 +44,6 @@ jobs: - name: Run check-endpoints run: | - python check-endpoints.py spec/openapi.yaml python check-endpoints.py --json spec/openapi.yaml > output.json 2>stderr.txt || { cat stderr.txt; exit 1; } - name: Update issue from check result @@ -87,6 +86,9 @@ jobs: sys.stdout.write("".join(parts)) PY ) + echo "----- check-endpoints issue body -----" + printf '%s\n' "$BODY" + echo "--------------------------------------" echo "$BODY" > body.md ISSUE_NUM=$(gh issue list --repo "${{ github.repository }}" --state open --limit 1 --json number --jq '.[0].number' --search 'in:title "Spec vs implementation: missing endpoints or params (auto-updated)"' 2>/dev/null || true) if [ -n "$ISSUE_NUM" ]; then From 4a9b1b4f8e7cbede308cc8e652be0c80a6ac1980 Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 13:57:52 -0500 Subject: [PATCH 28/29] remove push trigger --- .github/workflows/check-endpoints.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index c2da1c2..b045c66 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -12,11 +12,6 @@ on: # Weekly (e.g. for production): "0 3 * * 1" = Monday 03:00 UTC schedule: - cron: "0 3 * * 1" - push: - branches: ["feature/check-endpoints-ci"] - paths: - - ".github/workflows/check-endpoints.yml" - - "check-endpoints.py" jobs: check-endpoints: From 4f1f72dd2a3e0ec94563d372ee8c49c6a5d8220e Mon Sep 17 00:00:00 2001 From: Nick Stillman Date: Fri, 27 Feb 2026 14:10:46 -0500 Subject: [PATCH 29/29] fix comment --- .github/workflows/check-endpoints.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-endpoints.yml b/.github/workflows/check-endpoints.yml index b045c66..6e4809d 100644 --- a/.github/workflows/check-endpoints.yml +++ b/.github/workflows/check-endpoints.yml @@ -1,6 +1,6 @@ # Spec vs implementation: run check-endpoints.py against the latest Lichess API spec, # and create or update a single issue with a list of missing endpoints/params. -# See also: issue #6 (historical umbrella), Jira CHESS-xxx Rolling CI. +# See also: issue #6 (historical umbrella). # When enabling schedule: uncomment the schedule block below. # Duration: weekly. Cron "0 3 * * 1" = Monday 03:00 UTC (avoids overlap with 02:00 cassette refresh if present).