Repository navigation
feat: GitHub board (1/2): /counts data route returning issue and PR totals - #68
Conversation
There was a problem hiding this comment.
QA panel review — WARN
code-review-structural · head 0ac599a3abcb · formal
Low-risk PR adding a /counts endpoint, tests, and docs. No blockers or majors. Fix first: the /detail/pr route's number: int annotation breaks the {error} contract shared by all sibling detail routes. Verification refuted 5 of 10 findings — all were test-coverage claims that the verifier disproved by locating existing tests in test_counts_route.py, test_board_view.py, and test_detail_routes.py. No panel disagreement; no gaps.
Findings
| Severity | Location | Finding | Verified | |
|---|---|---|---|---|
| 🟡 | minor | api.py:306 |
GET /detail/pr declares number: int so FastAPI rejects non-numeric values with a 422 HTTP response, breaking the {error} contract that all four sibling detai… |
confirmed |
| 🟡 | minor | api.py:159 |
For state=closed the PR total includes merged PRs (GitHub search is:closed includes merged), but the board's PR list via gh pr list --state closed returns … |
confirmed |
| ⚪ | nit | api.py:262 |
Blocking filesystem call gh_available() runs on the event loop, violating the module's stated design principle that all blocking work runs in a worker thread. | confirmed |
| ⚪ | nit | api.py:361 |
Labels field accepts non-list values: a string like "bug" is iterated character-by-character, silently producing three single-character labels instead of one. | confirmed |
| ⚪ | nit | api.py:318 |
Unreachable except ValueError clause in _detail_number misleads readers into thinking int() can fail after the isascii/isdecimal guard. | confirmed |
findings JSON (machine-readable)
[
{
"file": "api.py",
"line": 306,
"severity": "minor",
"category": "correctness",
"claim": "GET /detail/pr declares `number: int` so FastAPI rejects non-numeric values with a 422 HTTP response, breaking the {error} contract that all four sibling detail routes uphold via `number: str` + `_detail_number()`.",
"evidence": "All four sibling detail routes (_detail_checks, _detail_diff, _detail_conversation, _detail_issue) declare `number: str` and pass it through `_detail_number()` so that a malformed value (e.g. ?number=abc) reaches the underlying fetch function and returns the API's `{\"error\": ...}` dict. _detail_pr instead declares `number: int`, so FastAPI's type validation rejects ?number=abc with a 422 HTTP resp",
"source": "protopatch",
"verdict": "confirmed",
"note": "api.py:306 `async def _detail_pr(repo: str, number: int, fresh: int = 0)` vs api.py:319/327/335/343 all use `number: str` + `_detail_number()`. FastAPI will 422 on ?number=abc for /detail/pr but return {\"error\":...} for the other four."
},
{
"file": "api.py",
"line": 159,
"severity": "minor",
"category": "correctness",
"claim": "For state=closed the PR total includes merged PRs (GitHub search `is:closed` includes merged), but the board's PR list via `gh pr list --state closed` returns only closed-unmerged PRs, so the closed-tab total will exceed the rows the board can list.",
"evidence": "# is:closed for PRs includes merged, matching gh pr list --state closed.\n qualifier = \"\" if norm == \"all\" else f\"is:{norm}\"",
"source": "protopatch",
"verdict": "confirmed",
"note": "api.py:158 uses `is:closed` in the GraphQL search for PRs. GitHub's `is:closed` for PRs includes merged PRs (well-documented search behavior), but `gh pr list --state closed` (used by fetch_prs at api.py:131) returns only closed-unmerged PRs. The code comment 'matching gh pr list --state closed' is incorrect \u2014 they do NOT match.",
"line_original": 158,
"line_corrected": true
},
{
"file": "api.py",
"line": 262,
"severity": "nit",
"category": "conventions",
"claim": "Blocking filesystem call gh_available() runs on the event loop, violating the module's stated design principle that all blocking work runs in a worker thread.",
"evidence": "The module docstring (lines 262\u2013263) states the design principle: 'The picker/default resolution may parse git remotes (blocking, cached) \u2014 every route runs it in a worker thread, never on the event loop.' The `_resolved()` helper correctly offloads `resolve_config` via `asyncio.to_thread`, but line 281 calls `gh_available()` directly on the event loop.",
"source": "protopatch",
"verdict": "confirmed",
"note": "api.py:281 `return {**resolved, \"gh_available\": gh_available()}` calls gh_available() (which calls resolve_gh() \u2192 filesystem PATH/install-dir checks) directly in the async _config handler. The docstring at api.py:262-263 explicitly states 'every route runs it in a worker thread, never on the event loop.'"
},
{
"file": "api.py",
"line": 361,
"severity": "nit",
"category": "correctness",
"claim": "Labels field accepts non-list values: a string like \"bug\" is iterated character-by-character, silently producing three single-character labels instead of one.",
"evidence": "`[str(x) for x in (body.get(\"labels\") or [])]` iterates over whatever value is in `body[\"labels\"]`. If a client sends a string like `{\"labels\": \"bug\"}` instead of `{\"labels\": [\"bug\"]}`, the comprehension yields `['b','u','g']` \u2014 three single-character labels \u2014 rather than one label.",
"source": "protopatch",
"verdict": "confirmed",
"note": "api.py:361 `labels = labels_for(kind, [str(x) for x in (body.get(\"labels\") or [])])` \u2014 if body[\"labels\"] is the string \"bug\", the for-loop iterates characters yielding ['b','u','g']. No type check on the value before iteration."
},
{
"file": "api.py",
"line": 318,
"severity": "nit",
"category": "conventions",
"claim": "Unreachable except ValueError clause in _detail_number misleads readers into thinking int() can fail after the isascii/isdecimal guard.",
"evidence": "The guard `value.isascii() and value.isdecimal()` ensures `value` contains only ASCII characters '0'-'9' (isdecimal() is False for '', '+', '-', underscores, and non-ASCII digit characters; isascii() excludes non-ASCII). `int()` on a non-empty string of ASCII digits never raises ValueError. The `try/except ValueError` block is therefore dead code.",
"source": "protopatch",
"verdict": "confirmed",
"note": "api.py:315-318: `return int(value) if value.isascii() and value.isdecimal() else value` \u2014 isdecimal() is True only for '0'-'9' (and some Unicode digits, but isascii() excludes those). int() on a non-empty string of '0'-'9' never raises ValueError. The except clause is unreachable.",
"line_original": 315,
"line_corrected": true
}
]There was a problem hiding this comment.
Promoting the WARN verdict for head 0ac599a3abcb: all checks terminal-green, zero unresolved review threads. (approve-on-green)
Open findings carried by this approval — non-blocking, but they did not go away:
- minor
api.py:306— GET /detail/pr declaresnumber: intso FastAPI rejects non-numeric values with a 422 HTTP response, breaking the {error} contract that all four sibling detail routes uphold vianumber: str+ `_det - minor
api.py:159— For state=closed the PR total includes merged PRs (GitHub searchis:closedincludes merged), but the board's PR list viagh pr list --state closedreturns only closed-unmerged PRs, so the closed-t - nit
api.py:262— Blocking filesystem call gh_available() runs on the event loop, violating the module's stated design principle that all blocking work runs in a worker thread. - nit
api.py:361— Labels field accepts non-list values: a string like "bug" is iterated character-by-character, silently producing three single-character labels instead of one. - nit
api.py:318— Unreachable except ValueError clause in _detail_number misleads readers into thinking int() can fail after the isascii/isdecimal guard.
Approving a WARN does not resolve its findings (issue #22).
Summary
Added the gated
/countsroute using one GraphQL request with separate issue and PR search variables. Validates filters and response shapes, returns readable errors, and reports zero issues for merged state.Documented the endpoint and added host-free tests. Ruff 0.15.10 lint and formatting checks pass; all 582 tests pass using installed tooling because this checkout lacks
.venv.