Skip to content

MRWK bounty: advisory attempt reservations#327

Closed
duongynhi000005-oss wants to merge 1 commit into
ramimbo:mainfrom
duongynhi000005-oss:agent5/bounty321-attempt-reservations
Closed

MRWK bounty: advisory attempt reservations#327
duongynhi000005-oss wants to merge 1 commit into
ramimbo:mainfrom
duongynhi000005-oss:agent5/bounty321-attempt-reservations

Conversation

@duongynhi000005-oss
Copy link
Copy Markdown

@duongynhi000005-oss duongynhi000005-oss commented May 25, 2026

Refs #321\n\nAdds advisory bounty attempt reservations with register/list/release/expire flows, warning surfacing, migration, and tests.\n\nValidation: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest -q

Summary by CodeRabbit

  • New Features

    • Bounty attempt submission system with automatic time-based expiration after a configurable period
    • Real-time tracking and warnings for multiple active submissions per bounty
    • New API endpoints to register, view, and manage bounty attempts
    • Admin controls to release attempts and transition lifecycle states
  • Tests

    • Added comprehensive test coverage for attempt tracking, expiration workflows, and API operations

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

📝 Walkthrough

Walkthrough

This PR introduces a bounty attempt system allowing submitters to register submission attempts for a bounty with automatic time-based expiry (4 hours default). Attempts transition through active/expired/released states and track submitter identity, source URLs, and branch references. The implementation spans database schema, ORM model, ledger service lifecycle management, and REST API endpoints with admin-controlled release capabilities.

Changes

Bounty Attempt System

Layer / File(s) Summary
BountyAttempt Data Model & Migration
app/models.py, migrations/versions/0004_bounty_attempts.py, app/db.py, tests/test_migrations.py
Defines BountyAttempt ORM model with columns for bounty linkage, submitter, source metadata, status, active_marker, and TTL expiration. Alembic migration creates bounty_attempts table with composite unique constraint on (bounty_id, submitter_account, active_marker) and indexes on bounty_id, status, and expires_at. Schema creation is conditional in _migrate_schema, and migration tests verify table and index existence.
Ledger Service—Attempt Lifecycle
app/ledger/service.py
Implements ATTEMPT_TTL_SECONDS constant (4 hours), helpers to compute current time and auto-expire due attempts, plus public functions: register_bounty_attempt (validates bounty open/slots/uniqueness, sets expires_at, stores active), release_bounty_attempt (verifies state, marks released, clears active_marker), and list_bounty_attempts (expires stale, filters by active_only, orders by descending id).
Ledger Tests
tests/test_ledger.py
Tests attempt registration and release across lifecycle cycles, verifying duplicate rejection during second registration phase. Tests expiration mechanics by monkeypatching utc_now, confirming status transitions to expired and submitter unblocking for new active attempts.
API Serialization & Endpoints
app/main.py
Adds bounty_attempt_to_dict() to serialize attempts as JSON. Extends GET /api/v1/bounties/{id} with attempts_active count and attempt_warnings list. New endpoints: GET /attempts (list with active_only filter), POST /attempts (register with ledger error→400 mapping), POST /attempts/{id}/release (admin-protected, verifies attempt belongs to bounty).
API Tests
tests/test_api_mcp.py
Tests concurrent attempt registration with multiple_active_attempts warning visibility and submitter appearance in lists. Tests admin release endpoint with bounty payment flow, verifying HTTP 400 rejection and "bounty is not open" detail when further attempts requested after payment.

Sequence Diagram

sequenceDiagram
  participant Client
  participant API as API Handler
  participant Service as Ledger Service
  participant DB as Database
  Client->>API: POST /bounties/{id}/attempts
  API->>Service: register_bounty_attempt()
  Service->>DB: expire due attempts
  Service->>DB: validate bounty open & slots
  Service->>DB: verify submitter uniqueness
  Service->>DB: insert active attempt with expires_at
  Service->>API: BountyAttempt
  API->>Client: 200 (attempt + warnings)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • #321: Implements the "bounty attempt" reservation system described in the issue, including the BountyAttempt model with TTL expiry, ledger helpers for registration/release/listing, and API endpoints with admin-controlled release and active-attempt warning visibility.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is largely incomplete, missing the summary details section, evidence of the problem being addressed, and test evidence checkboxes required by the template. Fill in the summary section with bullet points describing changes, add evidence section explaining the problem addressed, and complete the test evidence checklist.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding advisory attempt reservations for bounties with a clear focus on the feature implementation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/db.py`:
- Around line 89-106: The runtime schema migration in _migrate_schema is missing
the index on expires_at used by _expire_bounty_attempts; add a CREATE INDEX IF
NOT EXISTS ix_bounty_attempts_expires_at ON bounty_attempts (expires_at) within
the same connection.execute/text block that creates the other indexes (alongside
ix_bounty_attempts_bounty_id, ix_bounty_attempts_status and
uq_bounty_attempt_active) so the runtime schema matches the Alembic migration
and expiration queries remain efficient.

In `@app/main.py`:
- Around line 964-966: The code loads all attempts via list_bounty_attempts and
then filters in Python to count active items; change this to perform the
filter/count in the DB instead. Update list_bounty_attempts (or add a new
helper) to accept a status or active-only flag (e.g., status="active") or add a
dedicated count_active_bounty_attempts(session, bounty_id) that issues a DB
query counting rows WHERE bounty_id = bounty.id AND status = 'active', then
replace the current calls around list_bounty_attempts and
result["attempts_active"] to use that DB-level filtered count to avoid loading
inactive attempts.
- Around line 988-1053: The three endpoints api_bounty_attempts,
api_register_bounty_attempt, and api_release_bounty_attempt accept raw bounty_id
integers and skip the centralized bounds/positive check; update each to validate
bounty_id with the existing _positive_bounty_id guard (either by changing the
route parameter to bounty_id: int = Depends(_positive_bounty_id) or by
explicitly calling _positive_bounty_id(bounty_id) at the start of the function)
so invalid/too-large IDs produce the same 400 behavior and still enforce
SQLITE_INTEGER_MAX and related checks used by other bounty routes.

In `@migrations/versions/0004_bounty_attempts.py`:
- Around line 27-36: The migration is missing the index for the
submitter_account column declared in the ORM; add an op.create_index call to
create "ix_bounty_attempts_submitter_account" on the "bounty_attempts" table for
the ["submitter_account"] column (alongside the existing op.create_index calls
including
"ix_bounty_attempts_bounty_id"/"ix_bounty_attempts_status"/"ix_bounty_attempts_expires_at"
and the unique "uq_bounty_attempt_active"), and update the downgrade to drop
that index (drop_index "ix_bounty_attempts_submitter_account") so Alembic
migrations match the ORM model.

In `@tests/test_api_mcp.py`:
- Around line 1763-1847: Tests are missing assertions for the new GET
/api/v1/bounties/{bounty_id} fields (attempts_active / attempt_warnings) and for
the attempts-listing active_only filter; update the two test functions
(test_bounty_attempt_api_tracks_visibility_and_warnings and
test_bounty_attempt_api_rejects_paid_bounties_and_releases_attempts) to call GET
/api/v1/bounties/{bounty_id} and assert the response includes the correct
attempts_active and attempt_warnings values after creating/releases/paying
attempts, and add a call to GET
/api/v1/bounties/{bounty_id}/attempts?active_only=true to assert only active
attempts are returned and warnings behave as expected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3509e59a-5829-45c8-ba4f-df7db627bbdb

📥 Commits

Reviewing files that changed from the base of the PR and between 55608e3 and 2dce96d.

📒 Files selected for processing (8)
  • app/db.py
  • app/ledger/service.py
  • app/main.py
  • app/models.py
  • migrations/versions/0004_bounty_attempts.py
  • tests/test_api_mcp.py
  • tests/test_ledger.py
  • tests/test_migrations.py

Comment thread app/db.py
Comment on lines +89 to +106
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_bounty_attempts_bounty_id "
"ON bounty_attempts (bounty_id)"
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_bounty_attempts_status "
"ON bounty_attempts (status)"
)
)
connection.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_bounty_attempt_active "
"ON bounty_attempts (bounty_id, submitter_account, active_marker)"
)
)
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

Missing expires_at index in runtime migration.

The Alembic migration at line 30 creates ix_bounty_attempts_expires_at, but _migrate_schema does not. This index is used for efficient expiration queries in _expire_bounty_attempts. Consider adding it for consistency:

Proposed fix
         connection.execute(
             text(
                 "CREATE UNIQUE INDEX IF NOT EXISTS uq_bounty_attempt_active "
                 "ON bounty_attempts (bounty_id, submitter_account, active_marker)"
             )
         )
+        connection.execute(
+            text(
+                "CREATE INDEX IF NOT EXISTS ix_bounty_attempts_expires_at "
+                "ON bounty_attempts (expires_at)"
+            )
+        )
📝 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
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_bounty_attempts_bounty_id "
"ON bounty_attempts (bounty_id)"
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_bounty_attempts_status "
"ON bounty_attempts (status)"
)
)
connection.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_bounty_attempt_active "
"ON bounty_attempts (bounty_id, submitter_account, active_marker)"
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_bounty_attempts_bounty_id "
"ON bounty_attempts (bounty_id)"
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_bounty_attempts_status "
"ON bounty_attempts (status)"
)
)
connection.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_bounty_attempt_active "
"ON bounty_attempts (bounty_id, submitter_account, active_marker)"
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_bounty_attempts_expires_at "
"ON bounty_attempts (expires_at)"
)
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/db.py` around lines 89 - 106, The runtime schema migration in
_migrate_schema is missing the index on expires_at used by
_expire_bounty_attempts; add a CREATE INDEX IF NOT EXISTS
ix_bounty_attempts_expires_at ON bounty_attempts (expires_at) within the same
connection.execute/text block that creates the other indexes (alongside
ix_bounty_attempts_bounty_id, ix_bounty_attempts_status and
uq_bounty_attempt_active) so the runtime schema matches the Alembic migration
and expiration queries remain efficient.

Comment thread app/main.py
Comment on lines +964 to +966
attempts = list_bounty_attempts(session, bounty_id=bounty.id)
active_attempts = [attempt for attempt in attempts if attempt.status == "active"]
result["attempts_active"] = len(active_attempts)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Avoid loading inactive attempts when only active counts are needed.

Line 964 currently pulls all attempt rows, then Line 965 filters in Python. This is avoidable work on a hot detail endpoint and grows with historical attempt volume.

⚡ Proposed fix
-            attempts = list_bounty_attempts(session, bounty_id=bounty.id)
-            active_attempts = [attempt for attempt in attempts if attempt.status == "active"]
+            active_attempts = list_bounty_attempts(
+                session,
+                bounty_id=bounty.id,
+                include_inactive=False,
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/main.py` around lines 964 - 966, The code loads all attempts via
list_bounty_attempts and then filters in Python to count active items; change
this to perform the filter/count in the DB instead. Update list_bounty_attempts
(or add a new helper) to accept a status or active-only flag (e.g.,
status="active") or add a dedicated count_active_bounty_attempts(session,
bounty_id) that issues a DB query counting rows WHERE bounty_id = bounty.id AND
status = 'active', then replace the current calls around list_bounty_attempts
and result["attempts_active"] to use that DB-level filtered count to avoid
loading inactive attempts.

Comment thread app/main.py
Comment on lines +988 to +1053
@app.get("/api/v1/bounties/{bounty_id}/attempts")
def api_bounty_attempts(bounty_id: int, active_only: bool = Query(False)) -> dict[str, Any]:
with session_scope(db_url) as session:
bounty = session.get(Bounty, bounty_id)
if bounty is None:
raise HTTPException(status_code=404, detail="bounty not found")
attempts = list_bounty_attempts(
session,
bounty_id=bounty_id,
include_inactive=not active_only,
)
active_count = sum(1 for attempt in attempts if attempt.status == "active")
warnings: list[str] = []
if bounty.status != "open":
warnings.append("bounty_not_open")
if bounty.awards_paid >= bounty.max_awards:
warnings.append("no_award_slots_remaining")
if active_count > 1:
warnings.append("multiple_active_attempts")
return {
"bounty_id": bounty_id,
"active_count": active_count,
"warnings": warnings,
"attempts": [bounty_attempt_to_dict(attempt) for attempt in attempts],
}

@app.post("/api/v1/bounties/{bounty_id}/attempts")
async def api_register_bounty_attempt(bounty_id: int, request: Request) -> dict[str, Any]:
data = await _json_object(request)
with session_scope(db_url) as session:
try:
attempt = register_bounty_attempt(
session,
bounty_id=bounty_id,
submitter_account=_required_str(data, "submitter_account"),
source_url=_optional_str(data, "source_url") if data.get("source_url") is not None else None,
branch_ref=_optional_str(data, "branch_ref") if data.get("branch_ref") is not None else None,
expires_in_seconds=_optional_int(data, "expires_in_seconds", 14400),
)
except KeyError as exc:
raise HTTPException(status_code=400, detail=f"{exc.args[0]} is required") from exc
except LedgerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
attempts = list_bounty_attempts(session, bounty_id=bounty_id, include_inactive=False)
payload = bounty_attempt_to_dict(attempt)
payload["warnings"] = ["multiple_active_attempts"] if len(attempts) > 1 else []
return payload

@app.post("/api/v1/bounties/{bounty_id}/attempts/{attempt_id}/release")
async def api_release_bounty_attempt(
bounty_id: int,
attempt_id: int,
request: Request,
admin_login: str = Depends(require_admin_token),
) -> dict[str, Any]:
data = await _json_object(request)
released_by = _optional_str(data, "released_by", admin_login)
with session_scope(db_url) as session:
attempt = session.get(BountyAttempt, attempt_id)
if attempt is None or attempt.bounty_id != bounty_id:
raise HTTPException(status_code=404, detail="bounty attempt not found")
try:
released = release_bounty_attempt(session, attempt_id=attempt_id, released_by=released_by)
except LedgerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return bounty_attempt_to_dict(released)
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 | ⚡ Quick win

Apply the existing path-ID validation to all attempt endpoints.

Line 989, Line 1015, and Line 1038 accept raw IDs and skip _positive_bounty_id / integer bound checks used in other bounty routes. This creates inconsistent 400/404 behavior and bypasses centralized SQLITE_INTEGER_MAX guards.

🔧 Proposed fix
 `@app.get`("/api/v1/bounties/{bounty_id}/attempts")
 def api_bounty_attempts(bounty_id: int, active_only: bool = Query(False)) -> dict[str, Any]:
+    bounty_id = _positive_bounty_id(bounty_id)
     with session_scope(db_url) as session:
         bounty = session.get(Bounty, bounty_id)
         if bounty is None:
             raise HTTPException(status_code=404, detail="bounty not found")
@@
 `@app.post`("/api/v1/bounties/{bounty_id}/attempts")
 async def api_register_bounty_attempt(bounty_id: int, request: Request) -> dict[str, Any]:
+    bounty_id = _positive_bounty_id(bounty_id)
     data = await _json_object(request)
     with session_scope(db_url) as session:
@@
 `@app.post`("/api/v1/bounties/{bounty_id}/attempts/{attempt_id}/release")
 async def api_release_bounty_attempt(
     bounty_id: int,
     attempt_id: int,
     request: Request,
     admin_login: str = Depends(require_admin_token),
 ) -> dict[str, Any]:
+    bounty_id = _positive_bounty_id(bounty_id)
+    if attempt_id <= 0:
+        raise HTTPException(status_code=400, detail="attempt id must be positive")
+    if attempt_id > SQLITE_INTEGER_MAX:
+        raise HTTPException(status_code=400, detail="attempt id is too large")
     data = await _json_object(request)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/main.py` around lines 988 - 1053, The three endpoints
api_bounty_attempts, api_register_bounty_attempt, and api_release_bounty_attempt
accept raw bounty_id integers and skip the centralized bounds/positive check;
update each to validate bounty_id with the existing _positive_bounty_id guard
(either by changing the route parameter to bounty_id: int =
Depends(_positive_bounty_id) or by explicitly calling
_positive_bounty_id(bounty_id) at the start of the function) so
invalid/too-large IDs produce the same 400 behavior and still enforce
SQLITE_INTEGER_MAX and related checks used by other bounty routes.

Comment on lines +27 to +36
)
op.create_index("ix_bounty_attempts_bounty_id", "bounty_attempts", ["bounty_id"])
op.create_index("ix_bounty_attempts_status", "bounty_attempts", ["status"])
op.create_index("ix_bounty_attempts_expires_at", "bounty_attempts", ["expires_at"])
op.create_index(
"uq_bounty_attempt_active",
"bounty_attempts",
["bounty_id", "submitter_account", "active_marker"],
unique=True,
)
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

Missing index on submitter_account column.

The ORM model at app/models.py:78 defines submitter_account with index=True, but this migration does not create the corresponding ix_bounty_attempts_submitter_account index. This inconsistency means the index will exist when using Base.metadata.create_all() but not when using Alembic migrations.

Proposed fix
     op.create_index("ix_bounty_attempts_bounty_id", "bounty_attempts", ["bounty_id"])
+    op.create_index("ix_bounty_attempts_submitter_account", "bounty_attempts", ["submitter_account"])
     op.create_index("ix_bounty_attempts_status", "bounty_attempts", ["status"])

And in downgrade:

 def downgrade() -> None:
     op.drop_index("uq_bounty_attempt_active", table_name="bounty_attempts")
     op.drop_index("ix_bounty_attempts_expires_at", table_name="bounty_attempts")
     op.drop_index("ix_bounty_attempts_status", table_name="bounty_attempts")
+    op.drop_index("ix_bounty_attempts_submitter_account", table_name="bounty_attempts")
     op.drop_index("ix_bounty_attempts_bounty_id", table_name="bounty_attempts")
     op.drop_table("bounty_attempts")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@migrations/versions/0004_bounty_attempts.py` around lines 27 - 36, The
migration is missing the index for the submitter_account column declared in the
ORM; add an op.create_index call to create
"ix_bounty_attempts_submitter_account" on the "bounty_attempts" table for the
["submitter_account"] column (alongside the existing op.create_index calls
including
"ix_bounty_attempts_bounty_id"/"ix_bounty_attempts_status"/"ix_bounty_attempts_expires_at"
and the unique "uq_bounty_attempt_active"), and update the downgrade to drop
that index (drop_index "ix_bounty_attempts_submitter_account") so Alembic
migrations match the ORM model.

Comment thread tests/test_api_mcp.py
Comment on lines +1763 to +1847
def test_bounty_attempt_api_tracks_visibility_and_warnings(sqlite_url: str) -> None:
create_schema(sqlite_url)
with session_scope(sqlite_url) as session:
ensure_genesis(session)
bounty = create_bounty(
session,
repo="ramimbo/mergework",
issue_number=3213,
issue_url="https://github.com/ramimbo/mergework/issues/3213",
title="Attempt API",
reward_mrwk="20",
max_awards=2,
acceptance="Attempt API stays advisory.",
)
bounty_id = bounty.id

client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

first = client.post(
f"/api/v1/bounties/{bounty_id}/attempts",
json={"submitter_account": "github:alice", "expires_in_seconds": 60},
)
assert first.status_code == 200
assert first.json()["warnings"] == []

second = client.post(
f"/api/v1/bounties/{bounty_id}/attempts",
json={"submitter_account": "github:bob", "expires_in_seconds": 60},
)
assert second.status_code == 200
assert second.json()["warnings"] == ["multiple_active_attempts"]

visible = client.get(f"/api/v1/bounties/{bounty_id}/attempts").json()
assert visible["active_count"] == 2
assert visible["warnings"] == ["multiple_active_attempts"]
assert {item["submitter_account"] for item in visible["attempts"]} == {"github:alice", "github:bob"}


def test_bounty_attempt_api_rejects_paid_bounties_and_releases_attempts(
sqlite_url: str, monkeypatch
) -> None:
create_schema(sqlite_url)
with session_scope(sqlite_url) as session:
ensure_genesis(session)
bounty = create_bounty(
session,
repo="ramimbo/mergework",
issue_number=3214,
issue_url="https://github.com/ramimbo/mergework/issues/3214",
title="Attempt release",
reward_mrwk="20",
acceptance="Release admin flow should be tested.",
)
bounty_id = bounty.id

monkeypatch.setenv("MERGEWORK_ADMIN_TOKEN", "t" * 32)
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))
created = client.post(
f"/api/v1/bounties/{bounty_id}/attempts",
json={"submitter_account": "github:alice", "expires_in_seconds": 60},
).json()
released = client.post(
f"/api/v1/bounties/{bounty_id}/attempts/{created['id']}/release",
json={},
headers={"x-mergework-admin-token": "t" * 32},
)
assert released.status_code == 200
assert released.json()["status"] == "released"

with session_scope(sqlite_url) as session:
pay_bounty(
session,
bounty_id=bounty_id,
to_account="github:alice",
submission_url="https://github.com/ramimbo/mergework/pull/3214",
accepted_by="maintainer",
verifier_result={"label": "mrwk:accepted"},
)

rejected = client.post(
f"/api/v1/bounties/{bounty_id}/attempts",
json={"submitter_account": "github:bob", "expires_in_seconds": 60},
)
assert rejected.status_code == 400
assert rejected.json()["detail"] == "bounty is not open"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Expand tests to cover the full new API contract (detail warnings + active_only filter).

The new tests validate core flows, but they don’t assert the added attempts_active / attempt_warnings fields on GET /api/v1/bounties/{bounty_id} or active_only=true behavior on the attempts listing endpoint.

✅ Suggested additions
 def test_bounty_attempt_api_tracks_visibility_and_warnings(sqlite_url: str) -> None:
@@
     visible = client.get(f"/api/v1/bounties/{bounty_id}/attempts").json()
     assert visible["active_count"] == 2
     assert visible["warnings"] == ["multiple_active_attempts"]
     assert {item["submitter_account"] for item in visible["attempts"]} == {"github:alice", "github:bob"}
+    bounty_detail = client.get(f"/api/v1/bounties/{bounty_id}").json()
+    assert bounty_detail["attempts_active"] == 2
+    assert bounty_detail["attempt_warnings"] == ["multiple_active_attempts"]
@@
 def test_bounty_attempt_api_rejects_paid_bounties_and_releases_attempts(
@@
     assert released.status_code == 200
     assert released.json()["status"] == "released"
+    active_only = client.get(f"/api/v1/bounties/{bounty_id}/attempts?active_only=true").json()
+    assert active_only["active_count"] == 0
+    assert active_only["attempts"] == []

As per coding guidelines, tests/**/*.py: Add or update tests for changed behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_api_mcp.py` around lines 1763 - 1847, Tests are missing assertions
for the new GET /api/v1/bounties/{bounty_id} fields (attempts_active /
attempt_warnings) and for the attempts-listing active_only filter; update the
two test functions (test_bounty_attempt_api_tracks_visibility_and_warnings and
test_bounty_attempt_api_rejects_paid_bounties_and_releases_attempts) to call GET
/api/v1/bounties/{bounty_id} and assert the response includes the correct
attempts_active and attempt_warnings values after creating/releases/paying
attempts, and add a call to GET
/api/v1/bounties/{bounty_id}/attempts?active_only=true to assert only active
attempts are returned and warnings behave as expected.

Copy link
Copy Markdown
Contributor

@shootingallday shootingallday left a comment

Choose a reason for hiding this comment

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

Requesting changes because the branch currently fails the repository Ruff gate in touched files.

Evidence checked on PR head 2dce96db5dbcf3b242a235a0c473c0e0ba422c79:

  • uv run --extra dev python -m pytest tests/test_migrations.py tests/test_api_mcp.py::test_bounty_attempt_api_tracks_visibility_and_warnings -q -> 2 passed.
  • git diff --check origin/main...HEAD -> clean.
  • uv run --extra dev ruff check app/db.py app/main.py migrations/versions/0004_bounty_attempts.py tests/test_api_mcp.py -> failed.

Ruff failures observed:

  • app/main.py:1 import block is unsorted.
  • app/main.py:1023 and app/main.py:1024 line too long in optional source_url / branch_ref arguments.
  • app/main.py:1050 line too long in the release_bounty_attempt(...) call.
  • tests/test_api_mcp.py:1798 line too long in the attempt submitter set assertion.

Please run Ruff format/check on the touched files and push the formatting fix. After that, the feature behavior can be reviewed against the broader attempt-reservation semantics.

Copy link
Copy Markdown

@aunysillyme aunysillyme left a comment

Choose a reason for hiding this comment

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

Requesting changes because the attempt-reservation implementation has several schema/API correctness gaps beyond the already-reported formatting failure.

Evidence checked on the PR diff:

  • app/models.py declares submitter_account and expires_at with indexes, and Alembic creates ix_bounty_attempts_expires_at, but the runtime _migrate_schema() path in app/db.py creates only the bounty_id, status, and unique active-attempt indexes. Runtime-created databases therefore miss the expires_at index used by expiration scans.
  • The Alembic migration creates indexes for bounty_id, status, and expires_at, but it omits the submitter_account index declared by the ORM model. That makes migration-created schema differ from metadata for a field used in submitter-specific duplicate/release flows.
  • New routes under /api/v1/bounties/{bounty_id}/attempts accept raw bounty_id: int without the existing _positive_bounty_id guard used by other bounty endpoints, so oversized or invalid integer IDs will not get the same SQLite-bound-safe validation behavior.
  • The bounty-detail endpoint calls list_bounty_attempts() with the default inclusive mode and then filters active attempts in Python. That loads inactive/expired/released rows just to count active attempts; the helper should support an active-only/count query and use DB-level filtering.
  • Tests cover create/release basics, but do not assert the added attempts_active / attempt_warnings fields on GET /api/v1/bounties/{id}, nor the active_only=true listing behavior after release/expiration.

Requested fixes:

  1. Make runtime schema creation and Alembic migration match the ORM indexes, including expires_at and submitter_account.
  2. Route new bounty-attempt endpoints through the same positive/SQLite-bounded bounty-id validation as the existing bounty APIs.
  3. Use DB-level active attempt filtering/counting instead of loading all attempts and filtering in Python.
  4. Add API tests for bounty-detail warning fields and active_only=true listing behavior.

No secrets, wallet private keys, private payout details, deployment values, private vulnerability details, or MRWK price claims were reviewed or disclosed.

@ramimbo ramimbo added duplicate This issue or pull request already exists mrwk:rejected Submission rejected labels May 25, 2026
@ramimbo
Copy link
Copy Markdown
Owner

ramimbo commented May 25, 2026

Closing this as superseded by merged PR #326 for #321. This branch also has unresolved review findings around formatting and schema/API parity, so it is not accepted for this round.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

duplicate This issue or pull request already exists mrwk:rejected Submission rejected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants