From bff2104300d9161c3f03aff931635c4e43c667ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E5=98=89=E5=91=88?= Date: Tue, 26 May 2026 06:48:56 +0800 Subject: [PATCH 1/3] Add bounty attempt reservations --- app/main.py | 179 ++++++++++++++++- app/models.py | 15 ++ docs/agent-guide.md | 37 +++- docs/api-examples.md | 60 ++++++ migrations/versions/0004_bounty_attempts.py | 41 ++++ tests/test_bounty_attempts.py | 204 ++++++++++++++++++++ 6 files changed, 529 insertions(+), 7 deletions(-) create mode 100644 migrations/versions/0004_bounty_attempts.py create mode 100644 tests/test_bounty_attempts.py diff --git a/app/main.py b/app/main.py index 438a2f42..be023244 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 @@ -49,6 +49,7 @@ from app.models import ( Account, Bounty, + BountyAttempt, LedgerEntry, Proof, Submission, @@ -97,6 +98,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 +157,62 @@ 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 bounty_awards_to_dict(session: Session, bounty_id: int) -> list[dict[str, Any]]: proofs = session.scalars( select(Proof) @@ -946,6 +1006,123 @@ 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) -> JSONResponse: + bounty_id = _positive_bounty_id(bounty_id) + data = await _json_object(request) + submitter_account = _normalized_account(_required_str(data, "submitter_account")) + 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") + 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) + session.flush() + 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) -> 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 = _normalized_account(_required_str(data, "submitter_account")) + 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..d6347859 100644 --- a/app/models.py +++ b/app/models.py @@ -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,20 @@ class Submission(Base): bounty: Mapped[Bounty] = relationship(back_populates="submissions") +class BountyAttempt(Base): + __tablename__ = "bounty_attempts" + + 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..6600408e 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,25 @@ 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, 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. Release your 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:"}' +``` + Inspect an account or registered wallet: ```bash @@ -165,13 +188,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. Register an advisory attempt with `/api/v1/bounties/{id}/attempts` before + opening a PR when the bounty is active and has open award slots. +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..d28870e0 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -66,6 +66,66 @@ 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. 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..5969b057 --- /dev/null +++ b/migrations/versions/0004_bounty_attempts.py @@ -0,0 +1,41 @@ +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( + "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("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..1444e822 --- /dev/null +++ b/tests/test_bounty_attempts.py @@ -0,0 +1,204 @@ +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 create_app +from app.models import BountyAttempt, LedgerEntry + + +def test_bounty_attempts_register_list_duplicate_and_release(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=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")) + + 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"] + + 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 + + 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, +) -> None: + 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")) + + 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) -> None: + 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")) + + 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", + ] From fa6d143cfa4488e2c9447f74f0b39883fd47d22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E5=98=89=E5=91=88?= Date: Tue, 26 May 2026 07:00:57 +0800 Subject: [PATCH 2/3] Harden bounty attempt ownership --- app/main.py | 69 +++++++++++++++++++-- app/models.py | 12 +++- docs/agent-guide.md | 7 ++- docs/api-examples.md | 9 ++- migrations/versions/0004_bounty_attempts.py | 9 +++ tests/test_bounty_attempts.py | 29 ++++++++- 6 files changed, 119 insertions(+), 16 deletions(-) diff --git a/app/main.py b/app/main.py index be023244..48279da1 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -213,6 +214,19 @@ def bounty_attempt_warnings(session: Session, bounty: Bounty, now: datetime) -> 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) @@ -870,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: @@ -1027,10 +1050,14 @@ def api_bounty_attempts(bounty_id: int, include_expired: bool = Query(False)) -> } @app.post("/api/v1/bounties/{bounty_id}/attempts") - async def api_create_bounty_attempt(bounty_id: int, request: Request) -> JSONResponse: + 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 = _normalized_account(_required_str(data, "submitter_account")) + 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") @@ -1046,6 +1073,7 @@ async def api_create_bounty_attempt(bounty_id: int, request: Request) -> JSONRes 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( @@ -1084,7 +1112,32 @@ async def api_create_bounty_attempt(bounty_id: int, request: Request) -> JSONRes updated_at=now, ) session.add(attempt) - session.flush() + 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={ @@ -1095,13 +1148,17 @@ async def api_create_bounty_attempt(bounty_id: int, request: Request) -> JSONRes ) @app.post("/api/v1/bounty-attempts/{attempt_id}/release") - async def api_release_bounty_attempt(attempt_id: int, request: Request) -> dict[str, Any]: + 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 = _normalized_account(_required_str(data, "submitter_account")) + submitter_account = attempt_submitter_account(data, github_login) now = _utc_now() with session_scope(db_url) as session: attempt = session.get(BountyAttempt, attempt_id) diff --git a/app/models.py b/app/models.py index d6347859..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 @@ -71,6 +71,16 @@ class Submission(Base): 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) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 6600408e..bf235f93 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -50,8 +50,8 @@ 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, register a short-lived advisory attempt so other -agents can see overlapping work: +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" \ @@ -61,7 +61,8 @@ curl -s -X POST "$API_HOST/api/v1/bounties//attempts" \ Attempt reservations are visibility hints only. They do not create payments, claim acceptance, mutate ledger balances, or block maintainers from accepting -useful work. Release your attempt when you stop working: +useful work. `submitter_account` must match the authenticated GitHub login. +Release your attempt when you stop working: ```bash curl -s -X POST "$API_HOST/api/v1/bounty-attempts//release" \ diff --git a/docs/api-examples.md b/docs/api-examples.md index d28870e0..34f175ee 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -69,9 +69,12 @@ 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. Attempts are advisory only: -they do not create payments, claim acceptance, mutate ledger balances, or stop -maintainers from accepting useful work. +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: diff --git a/migrations/versions/0004_bounty_attempts.py b/migrations/versions/0004_bounty_attempts.py index 5969b057..89cd4326 100644 --- a/migrations/versions/0004_bounty_attempts.py +++ b/migrations/versions/0004_bounty_attempts.py @@ -24,6 +24,14 @@ def upgrade() -> None: 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", @@ -37,5 +45,6 @@ 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 index 1444e822..a649033c 100644 --- a/tests/test_bounty_attempts.py +++ b/tests/test_bounty_attempts.py @@ -7,11 +7,18 @@ 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 create_app +from app.main import _signed_value, create_app from app.models import BountyAttempt, LedgerEntry +COOKIE_SECRET = "test-cookie-secret" -def test_bounty_attempts_register_list_duplicate_and_release(sqlite_url: str) -> None: + +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) @@ -30,6 +37,7 @@ def test_bounty_attempts_register_list_duplicate_and_release(sqlite_url: str) -> ) 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", @@ -55,6 +63,13 @@ def test_bounty_attempts_register_list_duplicate_and_release(sqlite_url: str) -> 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}, @@ -77,6 +92,7 @@ def test_bounty_attempts_register_list_duplicate_and_release(sqlite_url: str) -> ) 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"}, @@ -105,7 +121,9 @@ def test_bounty_attempts_register_list_duplicate_and_release(sqlite_url: str) -> 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: @@ -132,6 +150,7 @@ def test_expired_bounty_attempt_is_visible_but_no_longer_blocks_submitter( ) 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"] == [] @@ -147,7 +166,10 @@ def test_expired_bounty_attempt_is_visible_but_no_longer_blocks_submitter( assert replacement.json()["attempt"]["status"] == "active" -def test_attempt_registration_rejects_closed_and_exhausted_bounties(sqlite_url: str) -> None: +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) @@ -180,6 +202,7 @@ def test_attempt_registration_rejects_closed_and_exhausted_bounties(sqlite_url: ) 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", From c2b2dc2988c2601d9da8d7d1a947138fcf197caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E5=98=89=E5=91=88?= Date: Tue, 26 May 2026 07:05:18 +0800 Subject: [PATCH 3/3] Clarify attempt reservation docs --- docs/agent-guide.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index bf235f93..ce5874c7 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -61,8 +61,8 @@ curl -s -X POST "$API_HOST/api/v1/bounties//attempts" \ 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. -Release your attempt when you stop working: +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" \ @@ -189,8 +189,8 @@ 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. Register an advisory attempt with `/api/v1/bounties/{id}/attempts` before - opening a PR when the bounty is active and has open award slots. +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.