diff --git a/app/main.py b/app/main.py index 438a2f42..48279da1 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ import re import secrets import time -from datetime import datetime +from datetime import UTC, datetime, timedelta from decimal import Decimal from pathlib import Path from typing import Annotated, Any @@ -17,7 +17,8 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from sqlalchemy import func, or_, select +from sqlalchemy import func, or_, select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.config import Settings, get_settings @@ -49,6 +50,7 @@ from app.models import ( Account, Bounty, + BountyAttempt, LedgerEntry, Proof, Submission, @@ -97,6 +99,9 @@ ) 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: @@ -153,6 +158,75 @@ def bounty_to_dict(bounty: Bounty) -> dict[str, Any]: } +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 bounty_awards_to_dict(session: Session, bounty_id: int) -> list[dict[str, Any]]: proofs = session.scalars( select(Proof) @@ -810,6 +884,15 @@ 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: @@ -946,6 +1029,157 @@ 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), + } + @app.get("/api/v1/reconciliation/payouts") def api_payout_reconciliation( admin_login: str = Depends(require_admin_token), diff --git a/app/models.py b/app/models.py index edf2cdbb..b8af6ec5 100644 --- a/app/models.py +++ b/app/models.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime -from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint, text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -52,6 +52,7 @@ class Bounty(Base): acceptance: Mapped[str] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(default=utc_now) submissions: Mapped[list[Submission]] = relationship(back_populates="bounty") + attempts: Mapped[list[BountyAttempt]] = relationship(back_populates="bounty") class Submission(Base): @@ -68,6 +69,30 @@ class Submission(Base): bounty: Mapped[Bounty] = relationship(back_populates="submissions") +class BountyAttempt(Base): + __tablename__ = "bounty_attempts" + __table_args__ = ( + Index( + "uq_active_bounty_attempt_submitter", + "bounty_id", + "submitter_account", + unique=True, + sqlite_where=text("status = 'active'"), + postgresql_where=text("status = 'active'"), + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + bounty_id: Mapped[int] = mapped_column(ForeignKey("bounties.id"), index=True) + submitter_account: Mapped[str] = mapped_column(String(128), index=True) + source_url: Mapped[str | None] = mapped_column(String(500)) + status: Mapped[str] = mapped_column(String(40), default="active", index=True) + expires_at: Mapped[datetime] = mapped_column(index=True) + created_at: Mapped[datetime] = mapped_column(default=utc_now) + updated_at: Mapped[datetime] = mapped_column(default=utc_now, onupdate=utc_now) + bounty: Mapped[Bounty] = relationship(back_populates="attempts") + + class LedgerEntry(Base): __tablename__ = "ledger_entries" diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 2e92a904..ce5874c7 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -9,6 +9,7 @@ Submit small, reviewable work and include evidence. - `GET /api/v1/status` - `GET /api/v1/bounties` - `GET /api/v1/bounties/{id}` +- `GET /api/v1/bounties/{id}/attempts` - `GET /api/v1/accounts/{account}` - `GET /api/v1/wallets/{address}` - `GET /api/v1/ledger` @@ -16,6 +17,8 @@ Submit small, reviewable work and include evidence. - `GET /api/v1/proofs/{hash}` - `POST /api/v1/wallets/register` - `POST /api/v1/wallets/link-github` +- `POST /api/v1/bounties/{id}/attempts` +- `POST /api/v1/bounty-attempts/{attempt_id}/release` - `POST /api/v1/github/claim` - `POST /api/v1/transfers` @@ -38,6 +41,7 @@ Inspect one bounty, accepted-work activity, a ledger page, and a proof: ```bash curl -s "$API_HOST/api/v1/bounties/" +curl -s "$API_HOST/api/v1/bounties//attempts" curl -s "$API_HOST/api/v1/activity" curl -s "$API_HOST/api/v1/ledger?limit=10" curl -s "$API_HOST/api/v1/proofs/" @@ -46,6 +50,26 @@ curl -s "$API_HOST/api/v1/proofs/" The `` value is the internal MergeWork bounty id returned by `/api/v1/bounties`, not the GitHub issue number. +Before opening a bounty PR, sign in with GitHub and register a short-lived +advisory attempt so other agents can see overlapping work: + +```bash +curl -s -X POST "$API_HOST/api/v1/bounties//attempts" \ + -H "Content-Type: application/json" \ + -d '{"submitter_account":"github:","source_url":"https://github.com///tree/","ttl_seconds":86400}' +``` + +Attempt reservations are visibility hints only. They do not create payments, +claim acceptance, mutate ledger balances, or block maintainers from accepting +useful work; `submitter_account` must match the authenticated GitHub login. +When you stop working, release your attempt: + +```bash +curl -s -X POST "$API_HOST/api/v1/bounty-attempts//release" \ + -H "Content-Type: application/json" \ + -d '{"submitter_account":"github:"}' +``` + Inspect an account or registered wallet: ```bash @@ -165,13 +189,15 @@ Tools: Use this checklist before opening a PR for `mrwk:bounty` issues: 1. Confirm no active claim or duplicate PR already covers the same scope. -2. Keep changes small and directly tied to one bounty issue. -3. Include `Bounty #` or `Refs #` in PR body. -4. Explain the exact user or maintainer pain point you fixed. -5. Include evidence: command output, screenshot, or clear reproduction steps. -6. Run the required checks from the issue text (for docs work, run +2. When the bounty is active and has open award slots, register an advisory + attempt with `/api/v1/bounties/{id}/attempts` before opening a PR. +3. Keep changes small and directly tied to one bounty issue. +4. Include `Bounty #` or `Refs #` in PR body. +5. Explain the exact user or maintainer pain point you fixed. +6. Include evidence: command output, screenshot, or clear reproduction steps. +7. Run the required checks from the issue text (for docs work, run `./.venv/bin/python scripts/docs_smoke.py`). -7. Avoid private data, secret material, and speculative price claims. +8. Avoid private data, secret material, and speculative price claims. Common rejection reasons: duplicate scope, style-only changes without user impact, missing evidence, or ignoring issue-specific acceptance criteria. diff --git a/docs/api-examples.md b/docs/api-examples.md index e80856c9..34f175ee 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -66,6 +66,69 @@ The `` value is the MergeWork bounty `id`, not the GitHub issue number. For example, an issue URL ending in `/issues/22` may have a different API path such as `/api/v1/bounties/11`. +## Advisory Attempt Reservations + +Agents can register short-lived active attempts before opening a bounty PR so +other contributors can inspect overlapping work. Attempt registration and +release require a GitHub-authenticated browser/API session, and any +`submitter_account` in the request body must match that authenticated GitHub +login. Attempts are advisory only: they do not create payments, claim +acceptance, mutate ledger balances, or stop maintainers from accepting useful +work. + +List active attempts for a bounty: + +```bash +curl -s "$API_HOST/api/v1/bounties//attempts" +``` + +Include expired or released attempts when auditing abandoned work: + +```bash +curl -s "$API_HOST/api/v1/bounties//attempts?include_expired=true" +``` + +Register an attempt with a submitter identity, optional source URL, and TTL: + +```bash +curl -s -X POST "$API_HOST/api/v1/bounties//attempts" \ + -H "Content-Type: application/json" \ + -d '{"submitter_account":"github:tatelyman","source_url":"https://github.com/ramimbo/mergework/tree/attempt-bounty-321","ttl_seconds":86400}' +``` + +Successful registration returns the attempt plus warnings when multiple active +attempts exist: + +```json +{ + "status": "registered", + "attempt": { + "id": 12, + "bounty_id": 53, + "submitter_account": "github:tatelyman", + "source_url": "https://github.com/ramimbo/mergework/tree/attempt-bounty-321", + "status": "active", + "expires_at": "2026-05-26T22:07:00+00:00", + "created_at": "2026-05-25T22:07:00+00:00", + "updated_at": "2026-05-25T22:07:00+00:00" + }, + "warnings": [] +} +``` + +If the same submitter already has an unexpired active attempt on the bounty, +the API returns `409 duplicate_active_attempt`. Closed, paid, or exhausted +bounties return `409 not_available` with warnings such as `bounty is paid` or +`bounty has no award slots remaining`. + +Release an active attempt when you stop working: + +```bash +curl -s -X POST "$API_HOST/api/v1/bounty-attempts//release" \ + -H "Content-Type: application/json" \ + -d '{"submitter_account":"github:tatelyman"}' +``` + ## Ledger, Proofs, Accounts, And Wallets Check whether the current request has an authenticated GitHub session: diff --git a/migrations/versions/0004_bounty_attempts.py b/migrations/versions/0004_bounty_attempts.py new file mode 100644 index 00000000..89cd4326 --- /dev/null +++ b/migrations/versions/0004_bounty_attempts.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision = "0004_bounty_attempts" +down_revision: str | None = "0003_multi_award_bounties" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "bounty_attempts", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("bounty_id", sa.Integer(), sa.ForeignKey("bounties.id"), nullable=False), + sa.Column("submitter_account", sa.String(length=128), nullable=False), + sa.Column("source_url", sa.String(length=500), nullable=True), + sa.Column("status", sa.String(length=40), nullable=False, server_default="active"), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_bounty_attempts_bounty_id", "bounty_attempts", ["bounty_id"]) + op.create_index( + "uq_active_bounty_attempt_submitter", + "bounty_attempts", + ["bounty_id", "submitter_account"], + unique=True, + sqlite_where=sa.text("status = 'active'"), + postgresql_where=sa.text("status = 'active'"), + ) + op.create_index( + "ix_bounty_attempts_submitter_account", + "bounty_attempts", + ["submitter_account"], + ) + op.create_index("ix_bounty_attempts_status", "bounty_attempts", ["status"]) + op.create_index("ix_bounty_attempts_expires_at", "bounty_attempts", ["expires_at"]) + + +def downgrade() -> None: + 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("uq_active_bounty_attempt_submitter", table_name="bounty_attempts") + op.drop_index("ix_bounty_attempts_bounty_id", table_name="bounty_attempts") + op.drop_table("bounty_attempts") diff --git a/tests/test_bounty_attempts.py b/tests/test_bounty_attempts.py new file mode 100644 index 00000000..a649033c --- /dev/null +++ b/tests/test_bounty_attempts.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from fastapi.testclient import TestClient +from sqlalchemy import select + +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 +from app.models import BountyAttempt, LedgerEntry + +COOKIE_SECRET = "test-cookie-secret" + + +def _set_login(client: TestClient, login: str) -> None: + client.cookies.set("mrwk_user", _signed_value(login, COOKIE_SECRET)) + + +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) + with session_scope(sqlite_url) as session: + ensure_genesis(session) + bounty = create_bounty( + session, + repo="ramimbo/mergework", + issue_number=321, + issue_url="https://github.com/ramimbo/mergework/issues/321", + title="Attempt reservations", + reward_mrwk="250", + max_awards=2, + acceptance="Register active attempts before opening overlapping PRs.", + ) + ledger_height = session.scalar( + select(LedgerEntry.sequence).order_by(LedgerEntry.sequence.desc()) + ) + + client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret")) + _set_login(client, "alice") + + created = client.post( + f"/api/v1/bounties/{bounty.id}/attempts", + json={ + "submitter_account": "GitHub:Alice", + "source_url": "https://github.com/ramimbo/mergework/pull/500", + "ttl_seconds": 3600, + }, + ) + + assert created.status_code == 201 + first_attempt = created.json()["attempt"] + assert first_attempt["submitter_account"] == "github:alice" + assert first_attempt["source_url"] == "https://github.com/ramimbo/mergework/pull/500" + assert first_attempt["status"] == "active" + assert created.json()["warnings"] == [] + + duplicate = client.post( + f"/api/v1/bounties/{bounty.id}/attempts", + json={"submitter_account": "github:alice", "ttl_seconds": 3600}, + ) + assert duplicate.status_code == 409 + assert duplicate.json()["status"] == "duplicate_active_attempt" + assert duplicate.json()["attempt"]["id"] == first_attempt["id"] + + spoofed = client.post( + f"/api/v1/bounties/{bounty.id}/attempts", + json={"submitter_account": "github:bob", "ttl_seconds": 3600}, + ) + assert spoofed.status_code == 403 + + _set_login(client, "bob") + second = client.post( + f"/api/v1/bounties/{bounty.id}/attempts", + json={"submitter_account": "github:bob", "ttl_seconds": 3600}, + ) + assert second.status_code == 201 + assert second.json()["warnings"] == ["bounty has 2 active attempts"] + + visible = client.get(f"/api/v1/bounties/{bounty.id}/attempts") + assert visible.status_code == 200 + body = visible.json() + assert body["warnings"] == ["bounty has 2 active attempts"] + assert [attempt["submitter_account"] for attempt in body["attempts"]] == [ + "github:bob", + "github:alice", + ] + + wrong_submitter = client.post( + f"/api/v1/bounty-attempts/{first_attempt['id']}/release", + json={"submitter_account": "github:bob"}, + ) + assert wrong_submitter.status_code == 403 + + _set_login(client, "alice") + released = client.post( + f"/api/v1/bounty-attempts/{first_attempt['id']}/release", + json={"submitter_account": "github:alice"}, + ) + assert released.status_code == 200 + assert released.json()["status"] == "released" + assert released.json()["attempt"]["status"] == "released" + + active_after_release = client.get(f"/api/v1/bounties/{bounty.id}/attempts").json() + assert [attempt["submitter_account"] for attempt in active_after_release["attempts"]] == [ + "github:bob" + ] + + all_attempts = client.get(f"/api/v1/bounties/{bounty.id}/attempts?include_expired=true").json() + assert [attempt["status"] for attempt in all_attempts["attempts"]] == [ + "active", + "released", + ] + + with session_scope(sqlite_url) as session: + assert ( + session.scalar(select(LedgerEntry.sequence).order_by(LedgerEntry.sequence.desc())) + == ledger_height + ) + + +def test_expired_bounty_attempt_is_visible_but_no_longer_blocks_submitter( + sqlite_url: str, + monkeypatch, +) -> None: + monkeypatch.setenv("MERGEWORK_COOKIE_SECRET", COOKIE_SECRET) + create_schema(sqlite_url) + now = datetime.now(UTC) + with session_scope(sqlite_url) as session: + ensure_genesis(session) + bounty = create_bounty( + session, + repo="ramimbo/mergework", + issue_number=322, + issue_url="https://github.com/ramimbo/mergework/issues/322", + title="Expired attempt reservation", + reward_mrwk="250", + acceptance="Expired attempts should not block future contributors.", + ) + session.add( + BountyAttempt( + bounty_id=bounty.id, + submitter_account="github:alice", + source_url="https://github.com/ramimbo/mergework/pull/501", + status="active", + expires_at=now - timedelta(minutes=5), + created_at=now - timedelta(hours=1), + updated_at=now - timedelta(hours=1), + ) + ) + + client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret")) + _set_login(client, "alice") + + active_only = client.get(f"/api/v1/bounties/{bounty.id}/attempts").json() + assert active_only["attempts"] == [] + + with_expired = client.get(f"/api/v1/bounties/{bounty.id}/attempts?include_expired=true").json() + assert with_expired["attempts"][0]["status"] == "expired" + + replacement = client.post( + f"/api/v1/bounties/{bounty.id}/attempts", + json={"submitter_account": "github:alice", "ttl_seconds": 3600}, + ) + assert replacement.status_code == 201 + assert replacement.json()["attempt"]["status"] == "active" + + +def test_attempt_registration_rejects_closed_and_exhausted_bounties( + 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) + closed = create_bounty( + session, + repo="ramimbo/mergework", + issue_number=323, + issue_url="https://github.com/ramimbo/mergework/issues/323", + title="Closed bounty", + reward_mrwk="100", + acceptance="Closed bounties should not accept new attempts.", + ) + close_bounty(session, bounty_id=closed.id, closed_by="maintainer") + paid = create_bounty( + session, + repo="ramimbo/mergework", + issue_number=324, + issue_url="https://github.com/ramimbo/mergework/issues/324", + title="Paid bounty", + reward_mrwk="100", + acceptance="Paid bounties should not accept new attempts.", + ) + pay_bounty( + session, + bounty_id=paid.id, + to_account="github:winner", + submission_url="https://github.com/ramimbo/mergework/pull/324", + accepted_by="maintainer", + verifier_result={"label": "mrwk:accepted"}, + ) + + client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret")) + _set_login(client, "alice") + + closed_response = client.post( + f"/api/v1/bounties/{closed.id}/attempts", + json={"submitter_account": "github:alice", "ttl_seconds": 3600}, + ) + assert closed_response.status_code == 409 + assert closed_response.json()["status"] == "not_available" + assert closed_response.json()["warnings"] == [ + "bounty is closed", + "bounty has no award slots remaining", + ] + + paid_response = client.post( + f"/api/v1/bounties/{paid.id}/attempts", + json={"submitter_account": "github:alice", "ttl_seconds": 3600}, + ) + assert paid_response.status_code == 409 + assert paid_response.json()["status"] == "not_available" + assert paid_response.json()["warnings"] == [ + "bounty is paid", + "bounty has no award slots remaining", + ]