diff --git a/app/bounty_attempts.py b/app/bounty_attempts.py new file mode 100644 index 0000000..e592f24 --- /dev/null +++ b/app/bounty_attempts.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +from fastapi import Depends, FastAPI, HTTPException, Query, Request +from fastapi.responses import JSONResponse +from sqlalchemy import func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.db import session_scope +from app.ledger.service import LedgerError, validate_public_url +from app.models import Bounty, BountyAttempt + +DEFAULT_ATTEMPT_TTL_SECONDS = 24 * 60 * 60 +MIN_ATTEMPT_TTL_SECONDS = 60 +MAX_ATTEMPT_TTL_SECONDS = 7 * 24 * 60 * 60 + +JsonObjectLoader = Callable[[Request], Awaitable[dict[str, Any]]] +LoginDependency = Callable[[Request], str] +RequiredString = Callable[[dict[str, Any], str], str] +OptionalInteger = Callable[[dict[str, Any], str, int], int] +NormalizeAccount = Callable[[str], str] +PositiveBountyId = Callable[[int], int] + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _attempt_effective_status(attempt: BountyAttempt, now: datetime) -> str: + if attempt.status == "active" and _as_utc(attempt.expires_at) <= now: + return "expired" + return attempt.status + + +async def _optional_json_object(request: Request, json_object: JsonObjectLoader) -> dict[str, Any]: + if not (await request.body()).strip(): + return {} + return await json_object(request) + + +def bounty_attempt_to_dict(attempt: BountyAttempt, now: datetime | None = None) -> dict[str, Any]: + now = _as_utc(now or _utc_now()) + return { + "id": attempt.id, + "bounty_id": attempt.bounty_id, + "submitter_account": attempt.submitter_account, + "source_url": attempt.source_url, + "status": _attempt_effective_status(attempt, now), + "expires_at": _as_utc(attempt.expires_at).isoformat(), + "created_at": _as_utc(attempt.created_at).isoformat(), + "updated_at": _as_utc(attempt.updated_at).isoformat(), + } + + +def _active_attempt_conditions(bounty_id: int, now: datetime) -> tuple[Any, ...]: + return ( + BountyAttempt.bounty_id == bounty_id, + BountyAttempt.status == "active", + BountyAttempt.expires_at > now, + ) + + +def bounty_attempt_warnings(session: Session, bounty: Bounty, now: datetime) -> list[str]: + warnings: list[str] = [] + awards_remaining = max(0, bounty.max_awards - bounty.awards_paid) + if bounty.status != "open": + warnings.append(f"bounty is {bounty.status}") + awards_remaining = 0 + if awards_remaining <= 0: + warnings.append("bounty has no award slots remaining") + active_count = session.scalar( + select(func.count()) + .select_from(BountyAttempt) + .where(*_active_attempt_conditions(bounty.id, now)) + ) + if active_count and active_count > 1: + warnings.append(f"bounty has {active_count} active attempts") + return warnings + + +def list_bounty_attempts( + session: Session, + bounty: Bounty, + *, + include_expired: bool = False, + limit: int | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + now = _as_utc(now or _utc_now()) + query = select(BountyAttempt).where(BountyAttempt.bounty_id == bounty.id) + if not include_expired: + query = query.where(*_active_attempt_conditions(bounty.id, now)) + query = query.order_by(BountyAttempt.created_at.desc(), BountyAttempt.id.desc()) + if limit is not None: + query = query.limit(limit) + attempts = session.scalars(query).all() + return { + "warnings": bounty_attempt_warnings(session, bounty, now), + "attempts": [bounty_attempt_to_dict(attempt, now) for attempt in attempts], + } + + +def expire_stale_bounty_attempts( + session: Session, bounty_id: int, now: datetime, submitter_account: str | None = None +) -> None: + query = update(BountyAttempt).where( + BountyAttempt.bounty_id == bounty_id, + BountyAttempt.status == "active", + BountyAttempt.expires_at <= now, + ) + if submitter_account is not None: + query = query.where(BountyAttempt.submitter_account == submitter_account) + session.execute(query.values(status="expired", updated_at=now)) + + +def register_bounty_attempt_routes( + app: FastAPI, + *, + db_url: str, + require_github_login: LoginDependency, + json_object: JsonObjectLoader, + required_str: RequiredString, + optional_int: OptionalInteger, + normalized_account: NormalizeAccount, + positive_bounty_id: PositiveBountyId, + sqlite_integer_max: int, +) -> None: + def attempt_submitter_account(data: dict[str, Any], github_login: str) -> str: + submitter_account = f"github:{github_login}" + if data.get("submitter_account") is None: + return submitter_account + requested_account = normalized_account(required_str(data, "submitter_account")) + if requested_account != submitter_account: + raise HTTPException(status_code=403, detail="submitter_account does not match login") + return submitter_account + + @app.get("/api/v1/bounties/{bounty_id}/attempts") + def api_bounty_attempts(bounty_id: int, include_expired: bool = Query(False)) -> dict[str, Any]: + bounty_id = positive_bounty_id(bounty_id) + now = _utc_now() + 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") + listing = list_bounty_attempts( + session, bounty, include_expired=include_expired, now=now + ) + return { + "bounty_id": bounty_id, + "warnings": listing["warnings"], + "attempts": listing["attempts"], + } + + @app.post("/api/v1/bounties/{bounty_id}/attempts") + async def api_create_bounty_attempt( + bounty_id: int, + request: Request, + github_login: str = Depends(require_github_login), + ) -> JSONResponse: + bounty_id = positive_bounty_id(bounty_id) + data = await _optional_json_object(request, json_object) + submitter_account = attempt_submitter_account(data, github_login) + ttl_seconds = optional_int(data, "ttl_seconds", DEFAULT_ATTEMPT_TTL_SECONDS) + if ttl_seconds < MIN_ATTEMPT_TTL_SECONDS: + raise HTTPException(status_code=400, detail="ttl_seconds must be at least 60") + if ttl_seconds > MAX_ATTEMPT_TTL_SECONDS: + raise HTTPException(status_code=400, detail="ttl_seconds must be no more than 604800") + source = data.get("source_url", "") + if source is None: + source = "" + if not isinstance(source, str): + raise HTTPException(status_code=400, detail="source_url must be a string") + source = source.strip() + try: + source_url = validate_public_url(source) if source else None + except LedgerError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + now = _utc_now() + 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") + expire_stale_bounty_attempts(session, bounty_id, now, submitter_account) + awards_remaining = max(0, bounty.max_awards - bounty.awards_paid) + if bounty.status != "open" or awards_remaining <= 0: + return JSONResponse( + status_code=409, + content={ + "status": "not_available", + "bounty_id": bounty_id, + "warnings": bounty_attempt_warnings(session, bounty, now), + }, + ) + existing = session.scalar( + select(BountyAttempt) + .where( + *_active_attempt_conditions(bounty_id, now), + BountyAttempt.submitter_account == submitter_account, + ) + .order_by(BountyAttempt.created_at.desc(), BountyAttempt.id.desc()) + .limit(1) + ) + if existing is not None: + return JSONResponse( + status_code=409, + content={ + "status": "duplicate_active_attempt", + "attempt": bounty_attempt_to_dict(existing, now), + "warnings": bounty_attempt_warnings(session, bounty, now), + }, + ) + attempt = BountyAttempt( + bounty_id=bounty_id, + submitter_account=submitter_account, + source_url=source_url, + status="active", + expires_at=now + timedelta(seconds=ttl_seconds), + created_at=now, + updated_at=now, + ) + session.add(attempt) + try: + session.flush() + except IntegrityError: + session.rollback() + bounty = session.get(Bounty, bounty_id) + existing = session.scalar( + select(BountyAttempt) + .where( + *_active_attempt_conditions(bounty_id, now), + BountyAttempt.submitter_account == submitter_account, + ) + .order_by(BountyAttempt.created_at.desc(), BountyAttempt.id.desc()) + .limit(1) + ) + if bounty is None or existing is None: + raise HTTPException( + status_code=409, detail="active attempt already exists" + ) from None + return JSONResponse( + status_code=409, + content={ + "status": "duplicate_active_attempt", + "attempt": bounty_attempt_to_dict(existing, now), + "warnings": bounty_attempt_warnings(session, bounty, now), + }, + ) + return JSONResponse( + status_code=201, + content={ + "status": "registered", + "attempt": bounty_attempt_to_dict(attempt, now), + "warnings": bounty_attempt_warnings(session, bounty, now), + }, + ) + + @app.post("/api/v1/bounty-attempts/{attempt_id}/release") + async def api_release_bounty_attempt( + attempt_id: int, + request: Request, + github_login: str = Depends(require_github_login), + ) -> dict[str, Any]: + 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 _optional_json_object(request, json_object) + submitter_account = attempt_submitter_account(data, github_login) + now = _utc_now() + with session_scope(db_url) as session: + attempt = session.get(BountyAttempt, attempt_id) + if attempt is None: + raise HTTPException(status_code=404, detail="attempt not found") + if attempt.submitter_account != submitter_account: + raise HTTPException(status_code=403, detail="submitter_account does not match") + effective_status = _attempt_effective_status(attempt, now) + if effective_status != "active": + return { + "status": f"already_{effective_status}", + "attempt": bounty_attempt_to_dict(attempt, now), + } + attempt.status = "released" + attempt.updated_at = now + session.flush() + return { + "status": "released", + "attempt": bounty_attempt_to_dict(attempt, now), + } diff --git a/app/main.py b/app/main.py index 7e7c4f9..bd8f873 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,6 @@ import re import secrets import time -from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Annotated, Any from urllib.parse import urlencode, urlsplit, urlunsplit @@ -16,8 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from sqlalchemy import func, or_, select, update -from sqlalchemy.exc import IntegrityError +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.admin import ( @@ -26,6 +24,10 @@ webhook_events_to_dict, webhook_status_summary, ) +from app.bounty_attempts import ( + list_bounty_attempts, + register_bounty_attempt_routes, +) from app.config import Settings, get_settings from app.db import create_schema, session_scope from app.ledger.reconciliation import payout_reconciliation_summary, reconcile_accepted_payouts @@ -52,7 +54,6 @@ from app.models import ( Account, Bounty, - BountyAttempt, LedgerEntry, Proof, Submission, @@ -113,9 +114,6 @@ ) API_DOCS_PATHS = {"/api/docs", "/api/redoc"} SQLITE_INTEGER_MAX = 2**63 - 1 -DEFAULT_ATTEMPT_TTL_SECONDS = 24 * 60 * 60 -MIN_ATTEMPT_TTL_SECONDS = 60 -MAX_ATTEMPT_TTL_SECONDS = 7 * 24 * 60 * 60 def _request_was_forwarded_https(request: Request) -> bool: @@ -149,75 +147,6 @@ def _issue_number_search_value(query: str) -> int | None: return issue_number if issue_number <= SQLITE_INTEGER_MAX else None -def _utc_now() -> datetime: - return datetime.now(UTC) - - -def _as_utc(value: datetime) -> datetime: - if value.tzinfo is None: - return value.replace(tzinfo=UTC) - return value.astimezone(UTC) - - -def _attempt_effective_status(attempt: BountyAttempt, now: datetime) -> str: - if attempt.status == "active" and _as_utc(attempt.expires_at) <= now: - return "expired" - return attempt.status - - -def bounty_attempt_to_dict(attempt: BountyAttempt, now: datetime | None = None) -> dict[str, Any]: - now = now or _utc_now() - return { - "id": attempt.id, - "bounty_id": attempt.bounty_id, - "submitter_account": attempt.submitter_account, - "source_url": attempt.source_url, - "status": _attempt_effective_status(attempt, now), - "expires_at": _as_utc(attempt.expires_at).isoformat(), - "created_at": _as_utc(attempt.created_at).isoformat(), - "updated_at": _as_utc(attempt.updated_at).isoformat(), - } - - -def _active_attempt_conditions(bounty_id: int, now: datetime) -> tuple[Any, ...]: - return ( - BountyAttempt.bounty_id == bounty_id, - BountyAttempt.status == "active", - BountyAttempt.expires_at > now, - ) - - -def bounty_attempt_warnings(session: Session, bounty: Bounty, now: datetime) -> list[str]: - warnings: list[str] = [] - awards_remaining = max(0, bounty.max_awards - bounty.awards_paid) - if bounty.status != "open": - warnings.append(f"bounty is {bounty.status}") - awards_remaining = 0 - if awards_remaining <= 0: - warnings.append("bounty has no award slots remaining") - active_count = session.scalar( - select(func.count()) - .select_from(BountyAttempt) - .where(*_active_attempt_conditions(bounty.id, now)) - ) - if active_count and active_count > 1: - warnings.append(f"bounty has {active_count} active attempts") - return warnings - - -def expire_stale_bounty_attempts( - session: Session, bounty_id: int, now: datetime, submitter_account: str | None = None -) -> None: - query = update(BountyAttempt).where( - BountyAttempt.bounty_id == bounty_id, - BountyAttempt.status == "active", - BountyAttempt.expires_at <= now, - ) - if submitter_account is not None: - query = query.where(BountyAttempt.submitter_account == submitter_account) - session.execute(query.values(status="expired", updated_at=now)) - - def _payout_response_from_proof(proof: Proof, *, status: str) -> dict[str, Any]: data = json.loads(proof.public_json) if not isinstance(data, dict) or data.get("kind") != "bounty_payment": @@ -545,15 +474,6 @@ def require_admin_token(request: Request) -> str: return "api-token" raise HTTPException(status_code=401, detail="admin token required") - def attempt_submitter_account(data: dict[str, Any], github_login: str) -> str: - submitter_account = f"github:{github_login}" - if data.get("submitter_account") is None: - return submitter_account - requested_account = _normalized_account(_required_str(data, "submitter_account")) - if requested_account != submitter_account: - raise HTTPException(status_code=403, detail="submitter_account does not match login") - return submitter_account - @app.get("/health") def health() -> dict[str, Any]: with session_scope(db_url) as session: @@ -670,156 +590,17 @@ def api_bounty(bounty_id: int) -> dict[str, Any]: result["accepted_awards"] = bounty_awards_to_dict(session, bounty.id) return result - @app.get("/api/v1/bounties/{bounty_id}/attempts") - def api_bounty_attempts(bounty_id: int, include_expired: bool = Query(False)) -> dict[str, Any]: - bounty_id = _positive_bounty_id(bounty_id) - now = _utc_now() - 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") - query = select(BountyAttempt).where(BountyAttempt.bounty_id == bounty_id) - if not include_expired: - query = query.where(*_active_attempt_conditions(bounty_id, now)) - attempts = session.scalars( - query.order_by(BountyAttempt.created_at.desc(), BountyAttempt.id.desc()) - ).all() - return { - "bounty_id": bounty_id, - "warnings": bounty_attempt_warnings(session, bounty, now), - "attempts": [bounty_attempt_to_dict(attempt, now) for attempt in attempts], - } - - @app.post("/api/v1/bounties/{bounty_id}/attempts") - async def api_create_bounty_attempt( - bounty_id: int, - request: Request, - github_login: str = Depends(require_github_login), - ) -> JSONResponse: - bounty_id = _positive_bounty_id(bounty_id) - data = await _json_object(request) - submitter_account = attempt_submitter_account(data, github_login) - ttl_seconds = _optional_int(data, "ttl_seconds", DEFAULT_ATTEMPT_TTL_SECONDS) - if ttl_seconds < MIN_ATTEMPT_TTL_SECONDS: - raise HTTPException(status_code=400, detail="ttl_seconds must be at least 60") - if ttl_seconds > MAX_ATTEMPT_TTL_SECONDS: - raise HTTPException(status_code=400, detail="ttl_seconds must be no more than 604800") - source = _optional_str(data, "source_url").strip() - try: - source_url = validate_public_url(source) if source else None - except LedgerError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - now = _utc_now() - 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") - expire_stale_bounty_attempts(session, bounty_id, now, submitter_account) - awards_remaining = max(0, bounty.max_awards - bounty.awards_paid) - if bounty.status != "open" or awards_remaining <= 0: - return JSONResponse( - status_code=409, - content={ - "status": "not_available", - "bounty_id": bounty_id, - "warnings": bounty_attempt_warnings(session, bounty, now), - }, - ) - existing = session.scalar( - select(BountyAttempt) - .where( - *_active_attempt_conditions(bounty_id, now), - BountyAttempt.submitter_account == submitter_account, - ) - .order_by(BountyAttempt.created_at.desc(), BountyAttempt.id.desc()) - .limit(1) - ) - if existing is not None: - return JSONResponse( - status_code=409, - content={ - "status": "duplicate_active_attempt", - "attempt": bounty_attempt_to_dict(existing, now), - "warnings": bounty_attempt_warnings(session, bounty, now), - }, - ) - attempt = BountyAttempt( - bounty_id=bounty_id, - submitter_account=submitter_account, - source_url=source_url, - status="active", - expires_at=now + timedelta(seconds=ttl_seconds), - created_at=now, - updated_at=now, - ) - session.add(attempt) - try: - session.flush() - except IntegrityError: - session.rollback() - bounty = session.get(Bounty, bounty_id) - existing = session.scalar( - select(BountyAttempt) - .where( - *_active_attempt_conditions(bounty_id, now), - BountyAttempt.submitter_account == submitter_account, - ) - .order_by(BountyAttempt.created_at.desc(), BountyAttempt.id.desc()) - .limit(1) - ) - if bounty is None or existing is None: - raise HTTPException( - status_code=409, detail="active attempt already exists" - ) from None - return JSONResponse( - status_code=409, - content={ - "status": "duplicate_active_attempt", - "attempt": bounty_attempt_to_dict(existing, now), - "warnings": bounty_attempt_warnings(session, bounty, now), - }, - ) - return JSONResponse( - status_code=201, - content={ - "status": "registered", - "attempt": bounty_attempt_to_dict(attempt, now), - "warnings": bounty_attempt_warnings(session, bounty, now), - }, - ) - - @app.post("/api/v1/bounty-attempts/{attempt_id}/release") - async def api_release_bounty_attempt( - attempt_id: int, - request: Request, - github_login: str = Depends(require_github_login), - ) -> dict[str, Any]: - 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) - submitter_account = attempt_submitter_account(data, github_login) - now = _utc_now() - with session_scope(db_url) as session: - attempt = session.get(BountyAttempt, attempt_id) - if attempt is None: - raise HTTPException(status_code=404, detail="attempt not found") - if attempt.submitter_account != submitter_account: - raise HTTPException(status_code=403, detail="submitter_account does not match") - effective_status = _attempt_effective_status(attempt, now) - if effective_status != "active": - return { - "status": f"already_{effective_status}", - "attempt": bounty_attempt_to_dict(attempt, now), - } - attempt.status = "released" - attempt.updated_at = now - session.flush() - return { - "status": "released", - "attempt": bounty_attempt_to_dict(attempt, now), - } + register_bounty_attempt_routes( + app, + db_url=db_url, + require_github_login=require_github_login, + json_object=_json_object, + required_str=_required_str, + optional_int=_optional_int, + normalized_account=_normalized_account, + positive_bounty_id=_positive_bounty_id, + sqlite_integer_max=SQLITE_INTEGER_MAX, + ) @app.get("/api/v1/reconciliation/payouts") def api_payout_reconciliation( @@ -1684,21 +1465,18 @@ def optional_bool_arg(field: str, default: bool = False) -> bool: bounty = session.get(Bounty, bounty_id) if bounty is None: return "bounty not found" - now = _utc_now() - attempt_query = select(BountyAttempt).where(BountyAttempt.bounty_id == bounty_id) - if not optional_bool_arg("include_expired"): - attempt_query = attempt_query.where(*_active_attempt_conditions(bounty_id, now)) - attempts = session.scalars( - attempt_query.order_by( - BountyAttempt.created_at.desc(), BountyAttempt.id.desc() - ).limit(list_limit_arg()) - ).all() + attempt_listing = list_bounty_attempts( + session, + bounty, + include_expired=optional_bool_arg("include_expired"), + limit=list_limit_arg(), + ) return { "bounty_id": bounty_id, "issue_number": bounty.issue_number, "status": bounty.status, - "warnings": bounty_attempt_warnings(session, bounty, now), - "attempts": [bounty_attempt_to_dict(attempt, now) for attempt in attempts], + "warnings": attempt_listing["warnings"], + "attempts": attempt_listing["attempts"], } if name == "get_balance": account = _normalized_account(str_arg("account")) diff --git a/tests/test_bounty_attempts.py b/tests/test_bounty_attempts.py index a649033..6349cb3 100644 --- a/tests/test_bounty_attempts.py +++ b/tests/test_bounty_attempts.py @@ -5,6 +5,7 @@ from fastapi.testclient import TestClient from sqlalchemy import select +from app.bounty_attempts import bounty_attempt_to_dict from app.db import create_schema, session_scope from app.ledger.service import close_bounty, create_bounty, ensure_genesis, pay_bounty from app.main import _signed_value, create_app @@ -17,6 +18,26 @@ def _set_login(client: TestClient, login: str) -> None: client.cookies.set("mrwk_user", _signed_value(login, COOKIE_SECRET)) +def test_bounty_attempt_serializer_reports_expired_effective_status() -> None: + now = datetime(2026, 5, 25, 12, 0) + stored_now = now.replace(tzinfo=UTC) + attempt = BountyAttempt( + id=7, + bounty_id=321, + submitter_account="github:alice", + source_url="https://github.com/ramimbo/mergework/pull/500", + status="active", + expires_at=stored_now - timedelta(minutes=1), + created_at=stored_now - timedelta(hours=2), + updated_at=stored_now - timedelta(hours=2), + ) + + payload = bounty_attempt_to_dict(attempt, now) + + assert payload["status"] == "expired" + assert payload["expires_at"] == "2026-05-25T11:59:00+00:00" + + def test_bounty_attempts_register_list_duplicate_and_release(sqlite_url: str, monkeypatch) -> None: monkeypatch.setenv("MERGEWORK_COOKIE_SECRET", COOKIE_SECRET) create_schema(sqlite_url) @@ -119,6 +140,38 @@ def test_bounty_attempts_register_list_duplicate_and_release(sqlite_url: str, mo ) +def test_bounty_attempts_accept_empty_body_defaults_to_login(sqlite_url: str, monkeypatch) -> None: + monkeypatch.setenv("MERGEWORK_COOKIE_SECRET", COOKIE_SECRET) + create_schema(sqlite_url) + with session_scope(sqlite_url) as session: + ensure_genesis(session) + bounty = create_bounty( + session, + repo="ramimbo/mergework", + issue_number=325, + issue_url="https://github.com/ramimbo/mergework/issues/325", + title="Bodyless attempt registration", + reward_mrwk="250", + acceptance="Default attempt account from the authenticated GitHub login.", + ) + + client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret")) + _set_login(client, "carol") + + created = client.post(f"/api/v1/bounties/{bounty.id}/attempts") + + assert created.status_code == 201 + attempt = created.json()["attempt"] + assert attempt["submitter_account"] == "github:carol" + assert attempt["source_url"] is None + + released = client.post(f"/api/v1/bounty-attempts/{attempt['id']}/release") + + assert released.status_code == 200 + assert released.json()["status"] == "released" + assert released.json()["attempt"]["status"] == "released" + + def test_expired_bounty_attempt_is_visible_but_no_longer_blocks_submitter( sqlite_url: str, monkeypatch,