Skip to content

Fix: honor offset parameter on bounty list endpoints#575

Open
piaigmt wants to merge 1 commit into
ramimbo:mainfrom
piaigmt:piaigmt/bounty-406-offset-pagination
Open

Fix: honor offset parameter on bounty list endpoints#575
piaigmt wants to merge 1 commit into
ramimbo:mainfrom
piaigmt:piaigmt/bounty-406-offset-pagination

Conversation

@piaigmt
Copy link
Copy Markdown

@piaigmt piaigmt commented May 28, 2026

Closes #406

The public API and MCP list_bounties tool accepted limit but ignored offset, making pagination impossible for clients that need to page through results.

Changes:

  • app/bounty_api.py: add offset to _list_bounties_by_status, api_bounties, and api_bounties_summary
  • app/public_routes.py: pass limit/offset through to the bounties HTML page
  • app/mcp_tools.py: add list_offset_arg() and apply offset to MCP list_bounties
  • tests/test_bounty_offset.py: regression tests for API, summary, public page, MCP tool, and negative-offset rejection

Validation:

  • pytest: 441 passed
  • ruff check/format: ok
  • mypy app: ok

Summary by CodeRabbit

  • New Features
    • Added offset-based pagination to bounty listings. Users can now navigate large result sets using the offset query parameter (0–10,000 range) across API endpoints and the web interface for improved browsing of bounties.

Review Change Stack

The public API and MCP list_bounties tool accepted limit but ignored offset,
making pagination impossible for clients that need to page through results.

Changes:
- app/bounty_api.py: add offset to _list_bounties_by_status, api_bounties,
  and api_bounties_summary
- app/public_routes.py: pass limit/offset through to the bounties HTML page
- app/mcp_tools.py: add list_offset_arg() and apply offset to MCP list_bounties
- tests/test_bounty_offset.py: regression tests for API, summary, public page,
  MCP tool, and negative-offset rejection

Validation: 441 passed, ruff check/format ok, mypy app ok.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

📝 Walkthrough

Walkthrough

Four files implement offset-based pagination (parameter bounds 0–10,000) across REST API (/api/v1/bounties, /api/v1/bounties/summary), MCP tool (list_bounties), and public HTML (/bounties) endpoints. The REST API forwards offset to a shared helper that slices sorted results; the MCP tool applies offset in database queries or array slicing; the public route passes offset through to a pagination-aware callable. A new test suite validates pagination behavior across all three entry points and rejects invalid input.

Changes

Offset Pagination Implementation

Layer / File(s) Summary
REST API offset pagination
app/bounty_api.py
/api/v1/bounties and /api/v1/bounties/summary gain offset query parameter (0–10,000). Internal _list_bounties_by_status helper computes start and end from offset and limit, then slices sorted bounty results. Both endpoints forward offset to the helper.
MCP tool offset pagination
app/mcp_tools.py
list_bounties MCP tool adds offset argument (validated 0–10,000, defaults to 0). When sort=newest, database query applies offset + limit. For other sort modes, all results are fetched and array-sliced using offset-based indices instead of fixed [:limit].
Public route offset pagination
app/public_routes.py
/bounties HTML route gains limit and offset query parameters and passes both to list_bounties_by_status via keyword arguments. Route handler type annotation loosened from fixed 3-argument callable to variadic Callable[..., list[dict[str, Any]]].
Integration tests
tests/test_bounty_offset.py
Suite of five test functions validates REST API pagination for various offset/limit combinations (including overshoot and edge cases), API summary bounties_shown consistency, public HTML content shifting by offset, MCP tool result slicing and ordering, and HTTP 422 rejection of negative offset.

Possibly Related PRs

  • ramimbo/mergework#441: Both PRs modify the shared _list_bounties_by_status helper and REST API bounty endpoints to add pagination controls; #441 added limit while this PR adds offset.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Bounty Pr Focus ⚠️ Warning PR scope correct but four unaddressed blockers: MCP rejects offset=0 vs REST allows it; summary test non-distinct; MCP test incomplete; public route missing bounds validation. Fix MCP offset validation (< not <=0); distinct bounty values in summary test; assert full MCP window; add bounds to public route parameters matching REST API.
Description check ❓ Inconclusive The description covers the issue, changes made, and validation results, but does not follow the repository template structure with required sections like 'Evidence' and checkbox items. Restructure the description to match the template: add 'Summary' section, 'Evidence' subsections (confusion addressed, capacity check, files, size, scope), and complete 'Test Evidence' checkboxes.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and concretely names the changed surface—adding offset parameter support to bounty list endpoints—matching the requirement to use a short, concrete title.
Linked Issues check ✅ Passed The PR addresses issue #406 acceptance criteria: it is a focused bug fix with regression tests, passes validation checks, evidence is documented, and work is linked to the issue.
Out of Scope Changes check ✅ Passed All changes directly address offset pagination support across the API, MCP tools, public routes, and tests, aligned with issue #406 requirements. No extraneous changes detected.
Mergework Public Artifact Hygiene ✅ Passed PR contains no investment, price, cash-out, or fabricated payout claims. Modified files are technical only. MRWK described properly per policy.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6023726f-781c-4d66-ba2f-752b20b98c4f

📥 Commits

Reviewing files that changed from the base of the PR and between 6a9d0e1 and f82642d.

📒 Files selected for processing (4)
  • app/bounty_api.py
  • app/mcp_tools.py
  • app/public_routes.py
  • tests/test_bounty_offset.py

Comment thread app/mcp_tools.py
Comment on lines +109 to +115
def list_offset_arg() -> int:
if "offset" not in args or args.get("offset") is None:
return 0
value = positive_int_arg("offset")
if value > 10_000:
raise ValueError("offset must be at most 10000")
return value
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Test that MCP list_bounties rejects offset=0 before fix

python -c "
import sys
import json
from app.mcp_tools import call_mcp_tool
from app.db import session_scope
from app.ledger.service import ensure_genesis, create_bounty
from tests.conftest import create_schema

# Use in-memory SQLite for quick test
db_url = 'sqlite:///:memory:'
create_schema(db_url)
with session_scope(db_url) as session:
    ensure_genesis(session)
    create_bounty(
        session,
        repo='test/repo',
        issue_number=1,
        issue_url='https://github.com/test/repo/issues/1',
        title='Test',
        reward_mrwk='10',
        acceptance='Test'
    )

try:
    result = call_mcp_tool(db_url, 'list_bounties', {'status': 'open', 'offset': 0})
    print('PASS: offset=0 accepted')
    sys.exit(0)
except ValueError as e:
    if 'must be positive' in str(e):
        print(f'FAIL: offset=0 rejected with: {e}')
        sys.exit(1)
    raise
"

Repository: ramimbo/mergework

Length of output: 296


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant helper implementations and list_offset_arg usage
echo "---- app/mcp_tools.py (list_offset_arg + helpers) ----"
python - <<'PY'
import itertools, pathlib
path = pathlib.Path("app/mcp_tools.py")
lines = path.read_text().splitlines()
def show(start,end):
    for i in range(start-1,end):
        if i<0 or i>=len(lines): continue
        print(f"{i+1:4d}: {lines[i]}")
# likely ranges; adjust if file shorter
show(1,120)
print("\n---- (continue) ----\n")
show(120,220)
PY

# Locate REST API offset validation
echo "---- bounty_api.py (offset Query bounds) ----"
python - <<'PY'
import pathlib, re
path = pathlib.Path("app/bounty_api.py")
if not path.exists():
    print("File app/bounty_api.py not found")
    raise SystemExit(0)
text = path.read_text().splitlines()
for i,line in enumerate(text,1):
    if "offset" in line and "Query" in line:
        print(f"{i:4d}: {line}")
PY

# Show the test that asserts offset=0 is accepted
echo "---- tests/test_bounty_offset.py ----"
python - <<'PY'
import pathlib
path = pathlib.Path("tests/test_bounty_offset.py")
if not path.exists():
    print("File tests/test_bounty_offset.py not found")
    raise SystemExit(0)
lines = path.read_text().splitlines()
for i in range(1,120):
    if i<=len(lines):
        print(f"{i:4d}: {lines[i-1]}")
PY

# Find positive_int_arg / int_arg definitions (if separate)
echo "---- Locate positive_int_arg and int_arg definitions ----"
rg -n "def (positive_int_arg|int_arg)\b" app/mcp_tools.py app -S || true

# Ensure where positive_int_arg is used
echo "---- Usages of positive_int_arg( ----"
rg -n "positive_int_arg\(" app -S || true

Repository: ramimbo/mergework

Length of output: 16728


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- tests/test_bounty_offset.py (continue from where we left off) ----"
python - <<'PY'
import pathlib
path = pathlib.Path("tests/test_bounty_offset.py")
lines = path.read_text().splitlines()
start=100
end=220
for i in range(start, min(end, len(lines))):
    print(f"{i+1:4d}: {lines[i]}")
PY

Repository: ramimbo/mergework

Length of output: 1619


Critical: MCP list_bounties rejects offset=0 while the REST API allows it

app/mcp_tools.py:list_offset_arg() calls positive_int_arg("offset"), which raises for value <= 0 (“offset must be positive”), so passing offset=0 fails. The REST endpoints allow offset=0 via Query(ge=0, le=10000), and tests/test_bounty_offset.py asserts ...&offset=0 returns the first page.

🐛 Proposed fix
 def list_offset_arg() -> int:
     if "offset" not in args or args.get("offset") is None:
         return 0
-    value = positive_int_arg("offset")
+    value = int_arg("offset")
+    if value < 0:
+        raise ValueError("offset must be non-negative")
     if value > 10_000:
         raise ValueError("offset must be at most 10000")
     return value

Add/extend an MCP test to cover offset=0 parity with the REST API.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def list_offset_arg() -> int:
if "offset" not in args or args.get("offset") is None:
return 0
value = positive_int_arg("offset")
if value > 10_000:
raise ValueError("offset must be at most 10000")
return value
def list_offset_arg() -> int:
if "offset" not in args or args.get("offset") is None:
return 0
value = int_arg("offset")
if value < 0:
raise ValueError("offset must be non-negative")
if value > 10_000:
raise ValueError("offset must be at most 10000")
return value

Comment thread app/mcp_tools.py
Comment on lines 147 to +159
sort = normalize_bounty_sort(optional_clean_str_arg("sort"))
limit = list_limit_arg()
offset = list_offset_arg()
if sort == "newest":
newest_bounties = session.scalars(
query.order_by(Bounty.id.desc()).limit(limit)
query.order_by(Bounty.id.desc()).offset(offset).limit(limit)
).all()
return json.dumps([bounty_to_dict(bounty) for bounty in newest_bounties])
bounties = session.scalars(query.order_by(Bounty.id.desc())).all()
sorted_bounties = sort_bounties([bounty_to_dict(bounty) for bounty in bounties], sort)
return json.dumps(sorted_bounties[:limit])
start = offset
end = start + limit
return json.dumps(sorted_bounties[start:end])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify code quality and type checking per guidelines

ruff format --check app/mcp_tools.py
ruff check app/mcp_tools.py
mypy app/mcp_tools.py

Repository: ramimbo/mergework

Length of output: 179


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ruff format --check .
ruff check .

if ! command -v mypy >/dev/null 2>&1; then
  python3 -m pip install -q mypy
fi

mypy app

Repository: ramimbo/mergework

Length of output: 429


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ruff format --check .
ruff check .

# Ensure mypy is installed, then run it without relying on PATH
python3 -m pip show mypy >/dev/null 2>&1 || python3 -m pip install -q mypy
python3 -m mypy app

Repository: ramimbo/mergework

Length of output: 14097


Ruff checks pass, but mypy app fails (type-checking compliance blocker).

  • ruff format --check . and ruff check . both pass.
  • mypy app fails with many errors, including app/mcp_tools.py:6 (“Cannot find implementation or library stub for module named "sqlalchemy"”), plus widespread FastAPI/SQLAlchemy missing-import issues and other mypy failures elsewhere—so the change doesn’t meet the “mypy app” guideline yet.

Comment thread app/public_routes.py
Comment on lines +73 to +74
limit: int | None = Query(None),
offset: int | None = Query(None),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consider adding validation bounds for limit and offset.

The REST API endpoints validate limit: Query(ge=1, le=200) and offset: Query(ge=0, le=10000) (bounty_api.py:136-137, 146-147), but the public route accepts any integer without bounds.

This allows unexpected inputs like negative offset (uses Python negative indexing) or extremely large values, creating inconsistency across entry points.

📋 Suggested bounds for consistency
-        limit: int | None = Query(None),
-        offset: int | None = Query(None),
+        limit: int | None = Query(None, ge=1, le=200),
+        offset: int | None = Query(None, ge=0, le=10000),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
limit: int | None = Query(None),
offset: int | None = Query(None),
limit: int | None = Query(None, ge=1, le=200),
offset: int | None = Query(None, ge=0, le=10000),

Comment on lines +56 to +64
for i in range(3):
create_bounty(
session,
repo="ramimbo/mergework",
issue_number=200 + i,
issue_url=f"https://github.com/ramimbo/mergework/issues/{200 + i}",
title=f"Summary bounty {i}",
reward_mrwk="20",
acceptance="Test acceptance.",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Summary pagination assertion doesn’t actually prove offset is honored.

On Line 72, asserting only bounties_shown == 1 can still pass if offset is ignored and only limit=1 is applied. Make seeded bounties value-distinct and assert an offset-sensitive aggregate (for example open_pool_mrwk) differs between offset 0 and 1.

Proposed test hardening
@@
-        for i in range(3):
+        for i in range(3):
             create_bounty(
                 session,
                 repo="ramimbo/mergework",
                 issue_number=200 + i,
                 issue_url=f"https://github.com/ramimbo/mergework/issues/{200 + i}",
                 title=f"Summary bounty {i}",
-                reward_mrwk="20",
+                reward_mrwk=str(20 + i),
                 acceptance="Test acceptance.",
             )
@@
-    full = client.get("/api/v1/bounties/summary").json()
-    assert full["bounties_shown"] == 3
-
-    limited = client.get("/api/v1/bounties/summary?limit=1&offset=1").json()
-    assert limited["bounties_shown"] == 1
+    full = client.get("/api/v1/bounties/summary").json()
+    assert full["bounties_shown"] == 3
+
+    first_page = client.get("/api/v1/bounties/summary?limit=1&offset=0").json()
+    second_page = client.get("/api/v1/bounties/summary?limit=1&offset=1").json()
+    assert first_page["bounties_shown"] == 1
+    assert second_page["bounties_shown"] == 1
+    assert first_page["open_pool_mrwk"] != second_page["open_pool_mrwk"]
As per coding guidelines, "tests/**/*.py: ... Focus on whether tests prove the changed behavior and include negative, replay, boundary, or regression cases where relevant."

Also applies to: 69-73

Comment on lines +121 to +125
result = call_mcp_tool(sqlite_url, "list_bounties", {"status": "open", "limit": 2, "offset": 1})
bounties = json.loads(result)
assert len(bounties) == 2
assert bounties[0]["title"] == "MCP bounty 2"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

MCP pagination test should assert the full returned window, not just the first row.

Line 124 checks only index 0; a broken second item/order could still pass. Assert both titles (or the whole title list) for the expected slice.

Proposed assertion tightening
     result = call_mcp_tool(sqlite_url, "list_bounties", {"status": "open", "limit": 2, "offset": 1})
     bounties = json.loads(result)
     assert len(bounties) == 2
-    assert bounties[0]["title"] == "MCP bounty 2"
+    assert [b["title"] for b in bounties] == ["MCP bounty 2", "MCP bounty 1"]
As per coding guidelines, "tests/**/*.py: ... Focus on whether tests prove the changed behavior and include negative, replay, boundary, or regression cases where relevant."

Copy link
Copy Markdown
Contributor

@akmhatey-ai akmhatey-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I independently checked current head f82642d587f38adc5bb9bc3b58604d38c41f9bef and found boundary issues that should be fixed before merge.

The API route and MCP/public route contracts are inconsistent:

  • /api/v1/bounties?offset=0 returns HTTP 200, but MCP list_bounties with {"status":"open","limit":1,"offset":0} raises ValueError: offset must be positive. Missing MCP offset defaults to 0, so explicit zero should not fail.
  • The public /bounties route declares limit and offset as plain optional ints, so invalid values are accepted and passed into Python slicing. Local repros on this head:
    • /bounties?offset=-1 -> HTTP 200
    • /bounties?limit=-1 -> HTTP 200 and omits the oldest row because [: -1] is applied
    • /bounties?limit=0 -> HTTP 200 with an empty page
    • /bounties?limit=10001 -> HTTP 200
  • The REST API rejects the same invalid cases as expected: /api/v1/bounties?offset=-1, limit=-1, limit=0, and limit=10001 all return 422.

Suggested fix: allow explicit MCP offset=0, and give the public route the same lower/upper bounds as the REST route before passing values into the shared list helper.

Validation I ran locally:

  • python -m pytest tests\test_bounty_offset.py -q -> 5 passed
  • python -m ruff check app\bounty_api.py app\mcp_tools.py app\public_routes.py tests\test_bounty_offset.py -> passed
  • python -m ruff format --check app\bounty_api.py app\mcp_tools.py app\public_routes.py tests\test_bounty_offset.py -> 4 files already formatted
  • python -m mypy app\bounty_api.py app\mcp_tools.py app\public_routes.py -> success
  • git diff --check origin/main...HEAD -> clean
  • diff-only Gitleaks scan -> no leaks

No private data, credentials, wallet material, signatures, production mutation, price/off-ramp, exchange/liquidity, or fabricated payout claims were used.

Copy link
Copy Markdown

@eliasx45 eliasx45 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head f82642d587f38adc5bb9bc3b58604d38c41f9bef.

Thanks for adding offset coverage across the bounty surfaces. I need to request changes because the public and MCP validation contracts diverge from the REST API in ways that are visible to callers.

Reproduced locally with three open bounties:

/api/v1/bounties?offset=-1   -> 422
/bounties?offset=-1          -> 200

/api/v1/bounties?limit=-1    -> 422
/bounties?limit=-1           -> 200, and Python slicing omits the oldest row

/api/v1/bounties?limit=0     -> 422
/bounties?limit=0            -> 200, empty page

/api/v1/bounties?limit=10001 -> 422
/bounties?limit=10001        -> 200

The public route currently declares limit: int | None = Query(None) and offset: int | None = Query(None), so invalid values are accepted and passed into slicing. It should use the same bounds as the REST route before calling the shared helper.

There is also an MCP zero-boundary mismatch:

call_mcp_tool(..., "list_bounties", {"status":"open","limit":1,"offset":0})
-> ValueError: offset must be positive

call_mcp_tool(..., "list_bounties", {"status":"open","limit":1})
-> succeeds, default offset is 0

Since REST accepts offset=0 and the MCP default is also zero, explicit MCP offset: 0 should not fail. Suggested fix: validate MCP offset with a non-negative bound, and give public /bounties the same limit 1..200 and offset 0..10000 FastAPI constraints as the REST endpoints.

Validation run locally:

  • pytest tests\\test_bounty_offset.py -q -> 5 passed
  • ruff check app\\bounty_api.py app\\mcp_tools.py app\\public_routes.py tests\\test_bounty_offset.py -> passed
  • ruff format --check app\\bounty_api.py app\\mcp_tools.py app\\public_routes.py tests\\test_bounty_offset.py -> 4 files already formatted
  • mypy app\\bounty_api.py app\\mcp_tools.py app\\public_routes.py -> success
  • scripts\\docs_smoke.py -> docs smoke ok
  • git diff --check origin/main...HEAD -> clean

@ramimbo
Copy link
Copy Markdown
Owner

ramimbo commented May 28, 2026

Maintainer cleanup note: holding this for changes. Reviews found the offset/limit contract still diverges across REST, public, and MCP paths, including invalid public offset/limit values and explicit MCP offset: 0. Please fix those boundaries before maintainer acceptance.

@ramimbo ramimbo added the mrwk:needs-info More information needed label May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mrwk:needs-info More information needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MRWK bounty: useful bug reports and small fixes, round 5

4 participants