diff --git a/app/main.py b/app/main.py index ce7683bd..08df504d 100644 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,11 @@ validate_public_url, ) from app.mcp import handle_mcp_request +from app.mcp_work_proof import ( + generic_work_proof_guidance_json, + work_proof_guidance, + work_proof_guidance_json, +) from app.me import me_page_context from app.models import ( Account, @@ -1273,189 +1278,6 @@ def list_limit_arg(default: int = 25) -> int: raise ValueError("limit must be at most 100") return value - def work_proof_guidance(bounty: Bounty) -> str: - bounty_data = bounty_to_dict(bounty) - availability = ( - "open for submissions" - if bounty_data["status"] == "open" and bounty_data["awards_remaining"] > 0 - else "not currently open for new submissions" - ) - return "\n".join( - [ - f"Bounty #{bounty_data['issue_number']}: {bounty_data['title']}", - f"Internal bounty id: {bounty_data['id']}", - f"Repository: {bounty_data['repo']}", - f"Issue: {bounty_data['issue_url']}", - ( - f"Status: {bounty_data['status']} ({availability}); " - f"awards remaining: {bounty_data['awards_remaining']} " - f"of {bounty_data['max_awards']}" - ), - f"Reward: {bounty_data['reward_mrwk']} MRWK per accepted award", - f"Acceptance: {bounty_data['acceptance']}", - ( - "Submit: open a focused PR or issue that links this bounty, include " - "specific test or behavior evidence, then comment /claim with the PR " - "or evidence URL and verification summary." - ), - ( - "Do not include private keys, seed material, secrets, deployment " - "credentials, private vulnerability details, or price claims." - ), - ] - ) - - def work_proof_submission_requirements( - *, - bounty_id: int | None, - issue_number: int | None, - can_submit: bool | None, - ) -> dict[str, Any]: - issue_ref = str(issue_number) if issue_number is not None else "" - bounty_ref = str(bounty_id) if bounty_id is not None else "" - if can_submit is True: - first_action = { - "id": "confirm_award_slot", - "required": True, - "text": "Confirm this bounty is open and has at least one award slot remaining.", - } - elif can_submit is False: - first_action = { - "id": "choose_open_bounty", - "required": True, - "text": ( - "Do not open or claim new work for this bounty unless a maintainer reopens it." - ), - } - else: - first_action = { - "id": "select_bounty", - "required": True, - "text": "Select a concrete open bounty before submitting work proof.", - } - return { - "reference_formats": [f"Bounty #{issue_ref}", f"Refs #{issue_ref}"], - "claim_command": "/claim", - "attempt_endpoint": f"/api/v1/bounties/{bounty_ref}/attempts", - "evidence_required": [ - "focused PR, issue, report, or evidence URL", - "short verification summary", - "tests, command output, screenshots, or reproduction steps when relevant", - ], - "acceptance_trigger": "maintainer_mrwk_accepted_label_or_admin_payout", - "public_metadata_must_avoid": [ - "private keys", - "seed material", - "secrets", - "deployment credentials", - "private vulnerability details", - "price claims", - ], - "next_actions": [ - first_action, - { - "id": "check_duplicate_scope", - "required": True, - "text": ( - "Confirm no active claim or duplicate PR already covers the same scope." - ), - }, - { - "id": "keep_scope_focused", - "required": True, - "text": "Keep changes directly tied to one bounty issue.", - }, - { - "id": "include_bounty_reference", - "required": True, - "text": ( - f"Include Bounty #{issue_ref} or Refs #{issue_ref} in the submission." - ), - }, - { - "id": "include_review_evidence", - "required": True, - "text": "Include reviewable validation evidence before claiming.", - }, - { - "id": "wait_for_maintainer_acceptance", - "required": True, - "text": ( - "Payment requires mrwk:accepted or an admin payout; merge or CI " - "alone is not acceptance." - ), - }, - ], - } - - def work_proof_guidance_json(bounty: Bounty) -> dict[str, Any]: - bounty_data = bounty_to_dict(bounty) - can_submit = bounty_data["status"] == "open" and bounty_data["awards_remaining"] > 0 - availability_warnings = [] - if bounty_data["status"] != "open": - availability_warnings.append(f"bounty is {bounty_data['status']}") - if bounty_data["awards_remaining"] <= 0: - availability_warnings.append("bounty has no award slots remaining") - return { - "bounty_id": bounty_data["id"], - "issue_number": bounty_data["issue_number"], - "status": bounty_data["status"], - "availability": "open_for_submissions" if can_submit else "not_currently_open", - "can_submit": can_submit, - "availability_warnings": availability_warnings, - "awards_remaining": bounty_data["awards_remaining"], - "max_awards": bounty_data["max_awards"], - "awards_paid": bounty_data["awards_paid"], - "reward_mrwk": bounty_data["reward_mrwk"], - "available_mrwk": bounty_data["available_mrwk"], - "repository": bounty_data["repo"], - "issue_url": bounty_data["issue_url"], - "title": bounty_data["title"], - "acceptance": bounty_data["acceptance"], - "submission_format": ( - "Open a focused PR or issue that links this bounty, include specific " - "test or behavior evidence, then comment /claim with the PR or " - "evidence URL and verification summary." - ), - "submission_requirements": work_proof_submission_requirements( - bounty_id=bounty_data["id"], - issue_number=bounty_data["issue_number"], - can_submit=can_submit, - ), - "safety_rules": [ - "Do not include private keys, seed material, secrets, deployment " - "credentials, private vulnerability details, or price claims." - ], - } - - def generic_work_proof_guidance_json() -> dict[str, Any]: - return { - "bounty_id": None, - "issue_number": None, - "status": "generic_guidance", - "availability": "unknown_without_bounty", - "can_submit": None, - "availability_warnings": [], - "awards_remaining": None, - "reward_mrwk": None, - "repository": None, - "issue_url": None, - "acceptance": None, - "submission_format": ( - "Open a focused PR or issue, reference the MRWK bounty, include test " - "evidence, and wait for a maintainer to apply mrwk:accepted." - ), - "submission_requirements": work_proof_submission_requirements( - bounty_id=None, - issue_number=None, - can_submit=None, - ), - "safety_rules": [ - "Do not include private keys, seed material, secrets, deployment " - "credentials, private vulnerability details, or price claims." - ], - } - def optional_bool_arg(field: str, default: bool = False) -> bool: value = args.get(field, default) if value is None: diff --git a/app/mcp_work_proof.py b/app/mcp_work_proof.py new file mode 100644 index 00000000..f5b4c989 --- /dev/null +++ b/app/mcp_work_proof.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from typing import Any, Literal + +from app.models import Bounty +from app.serializers import bounty_to_dict + +SubmissionAvailability = Literal["open", "full", "closed", "unknown"] + + +def work_proof_guidance(bounty: Bounty) -> str: + bounty_data = bounty_to_dict(bounty) + availability = ( + "open for submissions" + if bounty_data["status"] == "open" and bounty_data["awards_remaining"] > 0 + else "not currently open for new submissions" + ) + return "\n".join( + [ + f"Bounty #{bounty_data['issue_number']}: {bounty_data['title']}", + f"Internal bounty id: {bounty_data['id']}", + f"Repository: {bounty_data['repo']}", + f"Issue: {bounty_data['issue_url']}", + ( + f"Status: {bounty_data['status']} ({availability}); " + f"awards remaining: {bounty_data['awards_remaining']} " + f"of {bounty_data['max_awards']}" + ), + f"Reward: {bounty_data['reward_mrwk']} MRWK per accepted award", + f"Acceptance: {bounty_data['acceptance']}", + ( + "Submit: open a focused PR or issue that links this bounty, include " + "specific test or behavior evidence, then comment /claim with the PR " + "or evidence URL and verification summary." + ), + ( + "Do not include private keys, seed material, secrets, deployment " + "credentials, private vulnerability details, or price claims." + ), + ] + ) + + +def work_proof_submission_requirements( + *, + bounty_id: int | None, + issue_number: int | None, + availability: SubmissionAvailability, +) -> dict[str, Any]: + issue_ref = str(issue_number) if issue_number is not None else "" + bounty_ref = str(bounty_id) if bounty_id is not None else "" + if availability == "open": + first_action = { + "id": "confirm_award_slot", + "required": True, + "text": "Confirm this bounty is open and has at least one award slot remaining.", + } + elif availability == "full": + first_action = { + "id": "watch_for_award_slot", + "required": True, + "text": ( + "This bounty is open but has no award slots remaining; check for new " + "capacity before submitting new work." + ), + } + elif availability == "closed": + first_action = { + "id": "choose_open_bounty", + "required": True, + "text": "Do not open or claim new work for this bounty unless a maintainer reopens it.", + } + else: + first_action = { + "id": "select_bounty", + "required": True, + "text": "Select a concrete open bounty before submitting work proof.", + } + return { + "reference_formats": [f"Bounty #{issue_ref}", f"Refs #{issue_ref}"], + "claim_command": "/claim", + "attempt_endpoint": f"/api/v1/bounties/{bounty_ref}/attempts", + "evidence_required": [ + "focused PR, issue, report, or evidence URL", + "short verification summary", + "tests, command output, screenshots, or reproduction steps when relevant", + ], + "acceptance_trigger": "maintainer_mrwk_accepted_label_or_admin_payout", + "public_metadata_must_avoid": [ + "private keys", + "seed material", + "secrets", + "deployment credentials", + "private vulnerability details", + "price claims", + ], + "next_actions": [ + first_action, + { + "id": "check_duplicate_scope", + "required": True, + "text": "Confirm no active claim or duplicate PR already covers the same scope.", + }, + { + "id": "keep_scope_focused", + "required": True, + "text": "Keep changes directly tied to one bounty issue.", + }, + { + "id": "include_bounty_reference", + "required": True, + "text": f"Include Bounty #{issue_ref} or Refs #{issue_ref} in the submission.", + }, + { + "id": "include_review_evidence", + "required": True, + "text": "Include reviewable validation evidence before claiming.", + }, + { + "id": "wait_for_maintainer_acceptance", + "required": True, + "text": ( + "Payment requires mrwk:accepted or an admin payout; merge or CI " + "alone is not acceptance." + ), + }, + ], + } + + +def _submission_availability(bounty_data: dict[str, Any]) -> SubmissionAvailability: + if bounty_data["status"] == "open": + return "open" if bounty_data["awards_remaining"] > 0 else "full" + return "closed" + + +def work_proof_guidance_json(bounty: Bounty) -> dict[str, Any]: + bounty_data = bounty_to_dict(bounty) + submission_availability = _submission_availability(bounty_data) + can_submit = submission_availability == "open" + availability_warnings = [] + if bounty_data["status"] != "open": + availability_warnings.append(f"bounty is {bounty_data['status']}") + if bounty_data["awards_remaining"] <= 0: + availability_warnings.append("bounty has no award slots remaining") + return { + "bounty_id": bounty_data["id"], + "issue_number": bounty_data["issue_number"], + "status": bounty_data["status"], + "availability": "open_for_submissions" if can_submit else "not_currently_open", + "can_submit": can_submit, + "availability_warnings": availability_warnings, + "awards_remaining": bounty_data["awards_remaining"], + "max_awards": bounty_data["max_awards"], + "awards_paid": bounty_data["awards_paid"], + "reward_mrwk": bounty_data["reward_mrwk"], + "available_mrwk": bounty_data["available_mrwk"], + "repository": bounty_data["repo"], + "issue_url": bounty_data["issue_url"], + "title": bounty_data["title"], + "acceptance": bounty_data["acceptance"], + "submission_format": ( + "Open a focused PR or issue that links this bounty, include specific " + "test or behavior evidence, then comment /claim with the PR or " + "evidence URL and verification summary." + ), + "submission_requirements": work_proof_submission_requirements( + bounty_id=bounty_data["id"], + issue_number=bounty_data["issue_number"], + availability=submission_availability, + ), + "safety_rules": [ + "Do not include private keys, seed material, secrets, deployment " + "credentials, private vulnerability details, or price claims." + ], + } + + +def generic_work_proof_guidance_json() -> dict[str, Any]: + return { + "bounty_id": None, + "issue_number": None, + "status": "generic_guidance", + "availability": "unknown_without_bounty", + "can_submit": None, + "availability_warnings": [], + "awards_remaining": None, + "max_awards": None, + "awards_paid": None, + "reward_mrwk": None, + "available_mrwk": None, + "repository": None, + "issue_url": None, + "title": None, + "acceptance": None, + "submission_format": ( + "Open a focused PR or issue, reference the MRWK bounty, include test " + "evidence, and wait for a maintainer to apply mrwk:accepted." + ), + "submission_requirements": work_proof_submission_requirements( + bounty_id=None, + issue_number=None, + availability="unknown", + ), + "safety_rules": [ + "Do not include private keys, seed material, secrets, deployment " + "credentials, private vulnerability details, or price claims." + ], + } diff --git a/tests/test_api_mcp.py b/tests/test_api_mcp.py index aff3611b..dedc3ed2 100644 --- a/tests/test_api_mcp.py +++ b/tests/test_api_mcp.py @@ -1447,9 +1447,13 @@ def test_mcp_submit_work_proof_returns_structured_generic_guidance(sqlite_url: s "can_submit": None, "availability_warnings": [], "awards_remaining": None, + "max_awards": None, + "awards_paid": None, "reward_mrwk": None, + "available_mrwk": None, "repository": None, "issue_url": None, + "title": None, "acceptance": None, "submission_format": ( "Open a focused PR or issue, reference the MRWK bounty, include test " diff --git a/tests/test_mcp_work_proof.py b/tests/test_mcp_work_proof.py new file mode 100644 index 00000000..7d8d2f6f --- /dev/null +++ b/tests/test_mcp_work_proof.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from app.mcp_work_proof import ( + generic_work_proof_guidance_json, + work_proof_guidance, + work_proof_guidance_json, + work_proof_submission_requirements, +) +from app.models import Bounty + + +def _action(requirements: dict[str, object], action_id: str) -> dict[str, object]: + next_actions = requirements["next_actions"] + assert isinstance(next_actions, list) + matches = [action for action in next_actions if action["id"] == action_id] + assert len(matches) == 1 + return matches[0] + + +def _bounty(**overrides: object) -> Bounty: + values = { + "id": 7, + "repo": "ramimbo/mergework", + "issue_number": 377, + "issue_url": "https://github.com/ramimbo/mergework/issues/377", + "title": "Code health boundary", + "reward_microunits": 200_000_000, + "reserved_microunits": 1_200_000_000, + "max_awards": 6, + "awards_paid": 0, + "status": "open", + "acceptance": "Extract a coherent subsystem with focused tests.", + "created_at": datetime(2026, 5, 26, tzinfo=UTC), + } + values.update(overrides) + return Bounty(**values) + + +def test_work_proof_submission_requirements_choose_open_bounty_for_closed_state() -> None: + requirements = work_proof_submission_requirements( + bounty_id=7, + issue_number=377, + availability="closed", + ) + + assert requirements["reference_formats"] == ["Bounty #377", "Refs #377"] + assert requirements["attempt_endpoint"] == "/api/v1/bounties/7/attempts" + assert _action(requirements, "choose_open_bounty") == { + "id": "choose_open_bounty", + "required": True, + "text": "Do not open or claim new work for this bounty unless a maintainer reopens it.", + } + assert "price claims" in requirements["public_metadata_must_avoid"] + + +def test_work_proof_submission_requirements_distinguishes_full_bounty_state() -> None: + requirements = work_proof_submission_requirements( + bounty_id=7, + issue_number=377, + availability="full", + ) + + assert requirements["reference_formats"] == ["Bounty #377", "Refs #377"] + assert requirements["attempt_endpoint"] == "/api/v1/bounties/7/attempts" + assert _action(requirements, "watch_for_award_slot") == { + "id": "watch_for_award_slot", + "required": True, + "text": ( + "This bounty is open but has no award slots remaining; check for new " + "capacity before submitting new work." + ), + } + next_action_ids = {action["id"] for action in requirements["next_actions"]} + assert "choose_open_bounty" not in next_action_ids + assert "price claims" in requirements["public_metadata_must_avoid"] + + +def test_work_proof_guidance_json_reports_open_bounty_state() -> None: + guidance = work_proof_guidance_json(_bounty()) + + assert guidance["bounty_id"] == 7 + assert guidance["availability"] == "open_for_submissions" + assert guidance["can_submit"] is True + assert guidance["awards_remaining"] == 6 + assert guidance["max_awards"] == 6 + assert guidance["awards_paid"] == 0 + assert guidance["available_mrwk"] == "1200" + next_actions = guidance["submission_requirements"]["next_actions"] + assert any(action["id"] == "confirm_award_slot" for action in next_actions) + + +def test_work_proof_guidance_json_reports_open_full_bounty_state() -> None: + guidance = work_proof_guidance_json( + _bounty(awards_paid=6, reserved_microunits=0), + ) + + assert guidance["availability"] == "not_currently_open" + assert guidance["can_submit"] is False + assert guidance["availability_warnings"] == ["bounty has no award slots remaining"] + next_actions = guidance["submission_requirements"]["next_actions"] + assert any(action["id"] == "watch_for_award_slot" for action in next_actions) + + +def test_work_proof_guidance_returns_reviewable_text() -> None: + text = work_proof_guidance(_bounty()) + + assert "Bounty #377: Code health boundary" in text + assert "Repository: ramimbo/mergework" in text + assert "Status: open (open for submissions); awards remaining: 6 of 6" in text + assert "Reward: 200 MRWK per accepted award" in text + assert "Do not include private keys" in text + + +def test_generic_work_proof_guidance_reuses_shared_submission_requirements() -> None: + guidance = generic_work_proof_guidance_json() + + assert guidance["status"] == "generic_guidance" + assert guidance["max_awards"] is None + assert guidance["awards_paid"] is None + assert guidance["available_mrwk"] is None + assert guidance["title"] is None + assert guidance["submission_requirements"]["reference_formats"] == [ + "Bounty #", + "Refs #", + ] + next_actions = guidance["submission_requirements"]["next_actions"] + assert any(action["id"] == "select_bounty" for action in next_actions) + assert any("private keys" in rule for rule in guidance["safety_rules"])