Skip to content

Conversation

TAGOOZ
Copy link

@TAGOOZ TAGOOZ commented Oct 15, 2025

Fixes #3941

Summary

This PR implements automatic bounty payment processing when a pull request is merged that closes an issue with a bounty label. The system detects merged PRs, extracts linked issues, verifies bounty labels, and initiates payment through the GitHub Sponsors API.

Changes

Database

  • Added preferred_payment_method field to UserProfile model to store user's payment preference (GitHub Sponsors or Bitcoin Cash)
  • Created migration 0247_userprofile_preferred_payment_method.py

Backend API

  • Created new endpoint /api/bounty_payout/ in website/views/organization.py
  • Accepts POST requests with issue URL and optional PR URL
  • Validates issue has bounty label and assignee
  • Checks if user exists in BLT with linked GitHub account
  • Prevents duplicate payments by checking existing transaction IDs
  • Returns appropriate error messages for various failure cases
  • Added optional authentication via X-BLT-API-TOKEN header
  • Added URL route in blt/urls.py

GitHub Actions

  • Created workflow .github/workflows/auto-bounty-payout.yml
  • Triggers when PR is merged (not just closed)
  • Extracts linked issues from PR body using regex patterns
  • Checks each issue for dollar-amount labels ($5, $10, etc.)
  • Calls BLT API endpoint with issue details
  • Posts comment on issue confirming payment initiation or reporting errors

Implementation Details

The workflow looks for PR descriptions containing "Fixes #123", "Closes #456", or "Resolves #789" patterns to identify linked issues. For each linked issue with a bounty label, it makes an API call to process the payment.

The API endpoint validates the request, fetches issue details from GitHub, verifies the assignee has a BLT account with connected GitHub profile, checks their payment preference, and records the transaction. Currently uses placeholder transaction IDs pending full GitHub Sponsors GraphQL API integration.

Improvements Over Previous Attempt (PR #4236)

Based on CodeRabbit's review of the previous implementation, this version addresses several critical issues:

  • Uses curl for API calls instead of github.request() which only works for GitHub's own API
  • Properly reads authentication headers using request.headers.get() instead of making HTTP requests
  • No duplicate class definitions
  • Consistent data types throughout
  • Correct migration dependencies
  • Added duplicate payment prevention

Known Limitations

  • GitHub Sponsors API integration uses placeholder transaction IDs. Full GraphQL API implementation will be added in future work.
  • Only GitHub Sponsors payment method is currently implemented. Bitcoin Cash support is planned.
  • Requires BLT_API_TOKEN to be configured in settings and GitHub repository secrets.
  • Workflow API URL needs to be updated to production URL before deployment.

Testing

All Python files compile without syntax errors. Migration dependency has been verified against latest migration (0246). Error handling covers all expected failure cases including missing assignee, invalid issue URL, user not found, and duplicate payments.

Manual testing required after deployment to verify end-to-end workflow.

Deployment Notes

  1. Run migration: python manage.py migrate
  2. Add BLT_API_TOKEN to Django settings
  3. Add BLT_API_TOKEN to GitHub repository secrets
  4. Update API URL in workflow file (line 74) to production URL
  5. Restart application server

This is my first contribution to the project. I've tried to follow the existing code patterns and address feedback from previous attempts. Happy to make any changes based on review feedback.

Summary by CodeRabbit

  • New Features

    • API endpoint to process issue-linked bounty payouts with validation, idempotency checks, and JSON success/failure responses.
    • User profile preference for payout method (GitHub Sponsors or Bitcoin Cash).
  • Chores

    • Automation on merged PRs that detects linked issues with bounty labels, calls the payout API, and posts outcome comments with descriptive status and error details.

- Add preferred_payment_method field to UserProfile model
- Create /api/bounty_payout/ endpoint with authentication
- Add GitHub Action workflow to trigger on PR merge
- Implement duplicate payment prevention
- Add comprehensive error handling and validation

Addresses issues found in CodeRabbit review of PR OWASP-BLT#4236:
- Uses curl instead of github.request() for API calls
- Proper header authentication (X-BLT-API-TOKEN)
- No duplicate class definitions
- Consistent data types
- Fixed migration dependencies
Copy link
Contributor

coderabbitai bot commented Oct 15, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds a GitHub Actions workflow that triggers on merged PRs to detect linked issues with bounty labels and call a new CSRF-exempt API endpoint; introduces a UserProfile field and migration for preferred payment method; registers the new payout route and implements server-side payout processing and validations.

Changes

Cohort / File(s) Summary
Workflow: Auto Bounty Payout
.github/workflows/auto-bounty-payout.yml
New GitHub Actions workflow triggered on PR closed (runs only when merged). Parses PR body for linked issues, queries issue data, detects dollar-amount bounty labels, posts to BLT payout API, and comments success/failure on issues.
Routing: BLT API
blt/urls.py
Adds route path("api/bounty_payout/", csrf_exempt(process_bounty_payout), name="process_bounty_payout") and exposes process_bounty_payout for CSRF-exempt POSTs.
Data model & Migration
website/models.py, website/migrations/0247_userprofile_preferred_payment_method.py
Adds UserProfile.preferred_payment_method CharField (choices: sponsors, bch; default sponsors; null/blank allowed) and corresponding migration to add the field.
Bounty payout endpoint
website/views/organization.py
Adds process_bounty_payout(request) POST view: validates optional X-BLT-API-TOKEN, parses issue_url/pr_url, fetches GitHub issue, requires bounty label and assignee, maps assignee -> SocialAccount -> UserProfile, enforces Sponsors-only flow, creates/updates GitHubIssue payout metadata, prevents duplicate payouts, and returns JSON success/error responses.
Docs / PR description
PR_DESCRIPTION.md
Documents workflow, migration, API endpoint, implementation notes, testing and deployment guidance.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Dev as Developer
  participant GH as GitHub (PR & Issues)
  participant GA as GitHub Actions: Auto Bounty Payout
  participant BLT as BLT API /api/bounty_payout
  Dev->>GH: Merge PR
  GH-->>GA: PR closed (merged) event
  GA->>GA: Parse PR body for "Fixes/Closes/Resolves #n"
  alt Linked issues found
    loop For each issue
      GA->>GH: GET Issue via REST API
      GA->>GA: Detect bounty label ($...)
      alt Has bounty label
        GA->>BLT: POST {issue_url, pr_url} (X-BLT-API-TOKEN)
        alt 200 OK
          GA->>GH: Comment success on Issue
        else Non-200
          GA->>GH: Comment failure on Issue
        end
      else No bounty label
        GA->>GA: Skip issue
      end
    end
  else No linked issues
    GA->>GA: Exit
  end
Loading
sequenceDiagram
  autonumber
  participant GA as GitHub Actions
  participant BLT as BLT /api/bounty_payout
  participant GH as GitHub REST API
  participant DB as DB (GitHubIssue, UserProfile, SocialAccount)
  GA->>BLT: POST {issue_url, pr_url} (X-BLT-API-TOKEN)
  BLT->>BLT: Validate POST, parse JSON & token
  BLT->>GH: Fetch Issue (labels, assignee, repo)
  BLT->>BLT: Validate bounty label and assignee presence
  BLT->>DB: Find SocialAccount for GitHub assignee -> UserProfile
  BLT->>DB: Read UserProfile.preferred_payment_method
  alt preferred_payment_method == sponsors
    BLT->>BLT: Initiate Sponsors payout (placeholder/GraphQL)
    BLT->>DB: Create/Update GitHubIssue payout metadata (sponsors_tx_id, timestamps)
    BLT-->>GA: 200 {status, details}
  else other/none or already paid
    BLT-->>GA: 4xx/409 error JSON
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Linked Issues Check ⚠️ Warning The pull request fully implements the auto-bounty payout functionality described in issue #3941 but does not include any code changes for expiring email confirmation links (issue #123), the floating embeddable button (#456), or the search REST API (#789), leaving those objectives unaddressed. Please remove or separate the unrelated objectives from this PR or implement the missing features for issues #123, #456, and #789 in dedicated pull requests to ensure each linked issue is properly addressed.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title “feat: Implement auto bounty payout for merged PRs (fixes #3941)” clearly and concisely reflects the main change, focusing on the automatic bounty payout feature and referencing the relevant issue number without extraneous details or noise.
Out of Scope Changes Check ✅ Passed All code changes in this pull request directly support the auto-bounty payout feature (#3941), including the preferred payment field, API endpoint, URL routing, and GitHub Actions workflow, with no unrelated or extraneous modifications present.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Knowledge base: Disabled due to Reviews -> Disable Knowledge Base setting

📥 Commits

Reviewing files that changed from the base of the PR and between 7d18e0b and 6c8600a.

📒 Files selected for processing (5)
  • .github/workflows/auto-bounty-payout.yml (1 hunks)
  • blt/urls.py (2 hunks)
  • website/migrations/0247_userprofile_preferred_payment_method.py (1 hunks)
  • website/models.py (1 hunks)
  • website/views/organization.py (1 hunks)
🧰 Additional context used
🪛 actionlint (1.7.8)
.github/workflows/auto-bounty-payout.yml

18-18: "github.event.pull_request.body" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details

(expression)

🪛 GitHub Check: CodeQL
website/views/organization.py

[warning] 3951-3951: Information exposure through an exception
Stack trace information flows to this location and may be exposed to an external user.

.github/workflows/auto-bounty-payout.yml

[warning] 10-104: Workflow does not contain permissions
Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {{}}

🪛 Ruff (0.14.0)
website/views/organization.py

3766-3766: f-string without any placeholders

Remove extraneous f prefix

(F541)


3915-3915: Unpacked variable created is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)


3948-3948: Do not catch blind exception: Exception

(BLE001)


3949-3949: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


3949-3949: Use explicit conversion flag

Replace with conversion flag

(RUF010)

website/migrations/0247_userprofile_preferred_payment_method.py

8-10: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


12-25: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: auto-assign
  • GitHub Check: Run Tests
  • GitHub Check: docker-test
🔇 Additional comments (4)
website/models.py (1)

734-741: LGTM!

The preferred_payment_method field is correctly defined with appropriate choices, default value, and help text. The field configuration aligns with the migration and the bounty payout workflow requirements.

website/migrations/0247_userprofile_preferred_payment_method.py (1)

6-25: LGTM!

The migration correctly adds the preferred_payment_method field to the UserProfile model with proper configuration matching the model definition. The dependency on migration 0246 is appropriate.

Note: The Ruff warnings about ClassVar annotations are false positives specific to Django migrations and can be safely ignored.

blt/urls.py (1)

215-215: Authentication implementation verified
The process_bounty_payout view reads X-BLT-API-TOKEN, compares it to settings.BLT_API_TOKEN, and returns 401 on mismatch.

.github/workflows/auto-bounty-payout.yml (1)

56-56: Confirm bounty label precedence

The workflow selects only the first $-prefixed label when multiple are present; we found no historical issues with more than one. Ensure this aligns with your expected payout logic.

Comment on lines 3812 to 3946
issue_data = response.json()

# Check if issue has dollar tag (bounty)
has_bounty = False
bounty_amount = 0
for label in issue_data.get("labels", []):
label_name = label.get("name", "")
if label_name.startswith("$"):
has_bounty = True
# Extract amount from label (e.g., "$5" -> 5)
try:
bounty_amount = int(label_name.replace("$", "").strip())
except ValueError:
bounty_amount = 5 # Default to $5
break

if not has_bounty:
return JsonResponse(
{"success": False, "error": "Issue does not have a bounty label"},
status=400
)

# Get assignee username (the person who will receive payment)
assignee_username = None
if issue_data.get("assignee"):
assignee_username = issue_data["assignee"]["login"]
elif issue_data.get("assignees") and len(issue_data["assignees"]) > 0:
assignee_username = issue_data["assignees"][0]["login"]

if not assignee_username:
return JsonResponse(
{"success": False, "error": "Issue has no assignee to pay"},
status=400
)

logger.info(f"Processing payment for assignee: {assignee_username}")

# Find user profile by GitHub username
from allauth.socialaccount.models import SocialAccount

try:
social_account = SocialAccount.objects.get(
provider="github",
extra_data__login=assignee_username
)
user_profile = social_account.user.userprofile
except SocialAccount.DoesNotExist:
return JsonResponse(
{"success": False, "error": f"User with GitHub username {assignee_username} not found in BLT"},
status=404
)

# Check preferred payment method
payment_method = user_profile.preferred_payment_method or "sponsors"

# For this task, only handle GitHub Sponsors
if payment_method != "sponsors":
return JsonResponse(
{"success": False, "error": f"User prefers {payment_method} payment method, but only sponsors is supported in this implementation"},
status=400
)

# Process GitHub Sponsors payment
# Hardcoded to DonnieBLT as per issue requirements
sponsor_username = "DonnieBLT"

# Call GitHub Sponsors API to create payment
# Note: This is a placeholder - actual implementation would use GitHub Sponsors GraphQL API
# For now, we'll log the payment intent and mark it as processed

logger.info(f"Creating GitHub Sponsors payment: {sponsor_username} -> {assignee_username}, Amount: ${bounty_amount}")

# TODO: Implement actual GitHub Sponsors API call here
# This would require:
# 1. GitHub GraphQL API access
# 2. Proper authentication with sponsors scope
# 3. Creating a sponsorship transaction

# For now, create/update the GitHubIssue record
from website.models import Repo

repo_url = f"https://github.com/{owner}/{repo_name}"
repo, _ = Repo.objects.get_or_create(
repo_url=repo_url,
defaults={
"name": repo_name,
"slug": f"{owner}-{repo_name}",
},
)

# Check for duplicate payment before processing
try:
existing_issue = GitHubIssue.objects.get(issue_id=issue_data["id"], repo=repo)
if existing_issue.sponsors_tx_id or existing_issue.bch_tx_id:
logger.warning(f"Bounty already paid for issue #{issue_number}")
return JsonResponse({
"success": False,
"error": "Bounty already paid for this issue",
"paid_at": existing_issue.p2p_payment_created_at.isoformat() if existing_issue.p2p_payment_created_at else None
}, status=400)
except GitHubIssue.DoesNotExist:
pass # Issue doesn't exist yet, proceed with payment

github_issue, created = GitHubIssue.objects.update_or_create(
issue_id=issue_data["id"],
repo=repo,
defaults={
"title": issue_data["title"],
"body": issue_data.get("body", ""),
"state": issue_data["state"],
"url": issue_url,
"has_dollar_tag": True,
"p2p_amount_usd": bounty_amount,
"created_at": parse_datetime(issue_data["created_at"]),
"updated_at": parse_datetime(issue_data["updated_at"]),
"closed_at": parse_datetime(issue_data["closed_at"]) if issue_data.get("closed_at") else None,
"user_profile": user_profile,
}
)

# Mark as paid with sponsors (placeholder transaction ID)
# In production, this would be the actual transaction ID from GitHub Sponsors API
github_issue.sponsors_tx_id = f"pending_{timezone.now().timestamp()}"
github_issue.p2p_payment_created_at = timezone.now()
github_issue.save()

logger.info(f"Successfully processed bounty payout for issue #{issue_number}")

return JsonResponse({
"success": True,
"message": f"Bounty payout initiated for {assignee_username}",
"amount": bounty_amount,
"payment_method": "sponsors",
"issue_number": issue_number,
})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Block payouts until the issue is actually closed

After fetching the issue data we immediately proceed to pay out. There’s no guard that the issue is closed (or that the linked PR merged the issue), so a misfired workflow or a crafted request can mark a bounty as paid while the issue is still open. Please add a state check (and ideally confirm the PR merge) before creating the payout.

🧰 Tools
🪛 Ruff (0.14.0)

3915-3915: Unpacked variable created is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

🤖 Prompt for AI Agents
In website/views/organization.py around lines 3812-3946, the code proceeds to
payout immediately after fetching issue_data; add a guard that the issue is
closed and optionally confirm a linked PR is merged before any payout.
Concretely: check issue_data.get("state") == "closed" and return a 400 JSON
response if not closed; if the issue references a pull request (check issue_data
fields or search issue timeline), call the GitHub API to fetch the PR(s) and
verify pr["merged"] is True (return 400 if not merged); only after these checks
continue to create/update Repo/GitHubIssue and mark payout; add clear logger
messages for rejected payouts and ensure early returns prevent duplicate payment
logic from running.

@github-project-automation github-project-automation bot moved this from Backlog to Ready in 📌 OWASP BLT Project Board Oct 15, 2025
- Add explicit permissions block to workflow (least privilege)
- Fix script injection vulnerability by using environment variable for PR body
- Make API URL configurable via BLT_API_URL secret
- Fix exception handling to not expose stack traces
- Fix linting issues (unused variable, f-string without placeholders)
- Use logger.exception instead of logger.error for better debugging
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (3)
website/views/organization.py (3)

3915-3915: Prefix unused variable with underscore.

The _created variable from update_or_create is never used. While already prefixed with underscore, this was also flagged in the past review comments.

If you need to use the created flag for logging, keep it. Otherwise, the underscore prefix is sufficient to indicate it's intentionally unused.


3798-3812: Validate that the issue is closed before processing payment.

Per the previous review comment, the endpoint must verify the issue state is "closed" before initiating any payout. Currently, it fetches the issue but never checks issue_data["state"], allowing payments for open issues.

Apply this diff after line 3812:

 issue_data = response.json()
+
+# Verify issue is closed
+if issue_data.get("state") != "closed":
+    logger.warning(f"Attempted payout for non-closed issue #{issue_number}")
+    return JsonResponse(
+        {"success": False, "error": "Issue must be closed before payout"},
+        status=400
+    )

3772-3775: Validate that the linked PR is actually merged.

Per the previous review comment, the endpoint should verify that the PR mentioned in pr_url is merged before processing payment. Currently, pr_url is parsed but never used for validation.

Add PR merge validation after checking the issue state. You can add this new function before process_bounty_payout:

def verify_pr_merged(pr_url):
    """Verify that a pull request is merged."""
    if not pr_url:
        return True  # If no PR URL provided, skip validation
    
    # Extract PR details from URL
    parts = pr_url.rstrip("/").split("/")
    if len(parts) < 7 or parts[5] != "pull":
        return False
    
    owner = parts[3]
    repo_name = parts[4]
    pr_number = parts[6]
    
    # Fetch PR from GitHub API
    headers = {}
    if settings.GITHUB_TOKEN:
        headers["Authorization"] = f"token {settings.GITHUB_TOKEN}"
    
    api_url = f"https://api.github.com/repos/{owner}/{repo_name}/pulls/{pr_number}"
    try:
        response = requests.get(api_url, headers=headers, timeout=10)
        if response.status_code == 200:
            pr_data = response.json()
            return pr_data.get("merged", False)
    except requests.RequestException:
        pass
    
    return False

Then add this check after the issue state validation:

 if issue_data.get("state") != "closed":
     logger.warning(f"Attempted payout for non-closed issue #{issue_number}")
     return JsonResponse(
         {"success": False, "error": "Issue must be closed before payout"},
         status=400
     )
+
+# Verify linked PR is merged if provided
+if pr_url and not verify_pr_merged(pr_url):
+    logger.warning(f"Attempted payout for issue #{issue_number} with unmerged PR")
+    return JsonResponse(
+        {"success": False, "error": "Linked PR must be merged before payout"},
+        status=400
+    )
🧹 Nitpick comments (3)
website/views/organization.py (3)

3903-3913: Move duplicate payment check earlier to avoid unnecessary GitHub API calls.

The duplicate payment check occurs after fetching and validating the issue from GitHub. If the issue was already paid, we've wasted an API call and processing time. Check for duplicates immediately after parsing the issue_url.

Move the duplicate check to right after line 3796:

 owner = parts[3]
 repo_name = parts[4]
 issue_number = parts[6]
+
+# Check for duplicate payment early to avoid unnecessary API calls
+from website.models import Repo, GitHubIssue
+repo_url = f"https://github.com/{owner}/{repo_name}"
+repo = Repo.objects.filter(repo_url=repo_url).first()
+if repo:
+    existing_issue = GitHubIssue.objects.filter(url=issue_url, repo=repo).first()
+    if existing_issue and (existing_issue.sponsors_tx_id or existing_issue.bch_tx_id):
+        logger.warning(f"Bounty already paid for issue {issue_url}")
+        return JsonResponse({
+            "success": False,
+            "error": "Bounty already paid for this issue",
+            "paid_at": existing_issue.p2p_payment_created_at.isoformat() if existing_issue.p2p_payment_created_at else None
+        }, status=400)
 
 # Fetch issue from GitHub API
 headers = {}

Then remove the duplicate check at lines 3903-3913.


3876-3876: Remove unused hardcoded variable.

The sponsor_username variable is assigned but never used. Either remove it or use it in the actual GitHub Sponsors API implementation.

-# Process GitHub Sponsors payment
-# Hardcoded to DonnieBLT as per issue requirements
-sponsor_username = "DonnieBLT"
-
 # Call GitHub Sponsors API to create payment

3934-3936: Document the placeholder transaction ID format.

The placeholder transaction ID uses a timestamp-based format that isn't documented. This could cause confusion when the real GitHub Sponsors API is implemented, or if someone tries to validate the transaction ID format.

Add a comment explaining the format:

 # Mark as paid with sponsors (placeholder transaction ID)
 # In production, this would be the actual transaction ID from GitHub Sponsors API
+# Placeholder format: pending_{unix_timestamp} - will be replaced with actual Sponsors tx ID (format: ch_XXXX)
 github_issue.sponsors_tx_id = f"pending_{timezone.now().timestamp()}"
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Knowledge base: Disabled due to Reviews -> Disable Knowledge Base setting

📥 Commits

Reviewing files that changed from the base of the PR and between 6c8600a and b227de1.

📒 Files selected for processing (2)
  • .github/workflows/auto-bounty-payout.yml (1 hunks)
  • website/views/organization.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/auto-bounty-payout.yml
🧰 Additional context used
🪛 Ruff (0.14.0)
website/views/organization.py

3948-3948: Local variable e is assigned to but never used

Remove assignment to unused variable e

(F841)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Run Tests
  • GitHub Check: docker-test

Comment on lines +3747 to +3770
@require_POST
def process_bounty_payout(request):
"""
API endpoint to process bounty payouts for merged PRs with attached issues.
Called by GitHub Action when a PR is merged.
Expected POST data:
- issue_url: URL of the GitHub issue with bounty
- pr_url: URL of the merged PR (optional, for logging)
Expected Headers:
- X-BLT-API-TOKEN: API token for authentication (optional but recommended)
"""
try:
# Optional: Validate API token if provided
api_token = request.headers.get("X-BLT-API-TOKEN")
expected_token = getattr(settings, "BLT_API_TOKEN", None)

if expected_token and api_token != expected_token:
logger.warning("Invalid API token provided for bounty payout")
return JsonResponse(
{"success": False, "error": "Invalid API token"},
status=401
)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Add @csrf_exempt decorator to allow GitHub Actions to call this endpoint.

This endpoint is called by a GitHub Action workflow that cannot provide a CSRF token. Without @csrf_exempt, all requests will fail with 403 Forbidden due to Django's CSRF protection.

Apply this diff:

+from django.views.decorators.csrf import csrf_exempt
+
 @require_POST
+@csrf_exempt
 def process_bounty_payout(request):
     """
     API endpoint to process bounty payouts for merged PRs with attached issues.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@require_POST
def process_bounty_payout(request):
"""
API endpoint to process bounty payouts for merged PRs with attached issues.
Called by GitHub Action when a PR is merged.
Expected POST data:
- issue_url: URL of the GitHub issue with bounty
- pr_url: URL of the merged PR (optional, for logging)
Expected Headers:
- X-BLT-API-TOKEN: API token for authentication (optional but recommended)
"""
try:
# Optional: Validate API token if provided
api_token = request.headers.get("X-BLT-API-TOKEN")
expected_token = getattr(settings, "BLT_API_TOKEN", None)
if expected_token and api_token != expected_token:
logger.warning("Invalid API token provided for bounty payout")
return JsonResponse(
{"success": False, "error": "Invalid API token"},
status=401
)
from django.views.decorators.csrf import csrf_exempt
@require_POST
@csrf_exempt
def process_bounty_payout(request):
"""
API endpoint to process bounty payouts for merged PRs with attached issues.
Called by GitHub Action when a PR is merged.
Expected POST data:
- issue_url: URL of the GitHub issue with bounty
- pr_url: URL of the merged PR (optional, for logging)
Expected Headers:
- X-BLT-API-TOKEN: API token for authentication (optional but recommended)
"""
try:
# Optional: Validate API token if provided
api_token = request.headers.get("X-BLT-API-TOKEN")
expected_token = getattr(settings, "BLT_API_TOKEN", None)
if expected_token and api_token != expected_token:
logger.warning("Invalid API token provided for bounty payout")
return JsonResponse(
{"success": False, "error": "Invalid API token"},
status=401
)
🤖 Prompt for AI Agents
In website/views/organization.py around lines 3747 to 3770, the
process_bounty_payout view needs the @csrf_exempt decorator so GitHub Actions
can POST without a CSRF token; add from django.views.decorators.csrf import
csrf_exempt to the imports (if not already present) and place @csrf_exempt
directly above the @require_POST decorator on process_bounty_payout to disable
CSRF checks for this endpoint while keeping the existing token-based check.

}
)

# Mark as paid with sponsors (placeholder transaction ID)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please add the logic to process the payment

@DonnieBLT DonnieBLT requested a review from Copilot October 15, 2025 01:58
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR implements automated bounty payout processing when PRs are merged that close issues with bounty labels. The system detects linked issues from PR descriptions, validates bounty labels, and initiates payments through GitHub Sponsors API integration.

Key Changes

  • Added preferred payment method field to user profiles for bounty payout routing
  • Created API endpoint for processing bounty payouts with validation and duplicate prevention
  • Implemented GitHub Actions workflow to automatically trigger payouts on merged PRs

Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
website/views/organization.py Implements bounty payout API endpoint with issue validation, user lookup, and payment processing
website/models.py Adds preferred_payment_method field to UserProfile model for payment routing
website/migrations/0247_userprofile_preferred_payment_method.py Database migration to add the new payment preference field
blt/urls.py Registers the new bounty payout API endpoint
.github/workflows/auto-bounty-payout.yml GitHub Actions workflow to detect bounty issues and trigger automated payouts

Comment on lines 3807 to 3808
return JsonResponse(
{"success": False, "error": f"Failed to fetch issue from GitHub: {response.status_code}"},
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

Error message should not include generic exception details. Provide more specific information about the GitHub API failure.

Suggested change
return JsonResponse(
{"success": False, "error": f"Failed to fetch issue from GitHub: {response.status_code}"},
if response.status_code == 404:
error_msg = "GitHub issue not found."
elif response.status_code in (401, 403):
error_msg = "Authentication with GitHub failed or access denied."
elif response.status_code == 429:
error_msg = "GitHub API rate limit exceeded. Please try again later."
else:
error_msg = "Failed to fetch issue from GitHub."
return JsonResponse(
{"success": False, "error": error_msg},

Copilot uses AI. Check for mistakes.


# Mark as paid with sponsors (placeholder transaction ID)
# In production, this would be the actual transaction ID from GitHub Sponsors API
github_issue.sponsors_tx_id = f"pending_{timezone.now().timestamp()}"
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

Using timestamp as a placeholder transaction ID could create conflicts. Consider using UUIDs or a more robust identifier format.

Copilot uses AI. Check for mistakes.

Comment on lines 3949 to 3953
logger.exception("Error processing bounty payout")
return JsonResponse(
{"success": False, "error": "Internal server error"},
status=500
)
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

The error message follows the custom guideline to not include exception details but should be more descriptive about what operation failed.

Copilot generated this review using guidance from repository custom instructions.

logger.info(f"Processing payment for assignee: {assignee_username}")

# Find user profile by GitHub username
from allauth.socialaccount.models import SocialAccount
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

Import statements should be placed at the top of the file rather than inside functions for better readability and performance.

Copilot uses AI. Check for mistakes.

Comment on lines 3891 to 3892
from website.models import Repo

Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

Import statements should be placed at the top of the file rather than inside functions for better readability and performance.

Suggested change
from website.models import Repo

Copilot uses AI. Check for mistakes.

# BLT_API_URL can be set in repository secrets/variables for testing
# Defaults to production if not set
# Add BLT_API_TOKEN to repository secrets for authentication
api_url="${{ secrets.BLT_API_URL || 'https://blt.owasp.org' }}"
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

The hardcoded production URL should be moved to a configuration variable or environment-specific setting to avoid accidental production calls during testing.

Suggested change
api_url="${{ secrets.BLT_API_URL || 'https://blt.owasp.org' }}"
if [ -z "${{ secrets.BLT_API_URL }}" ]; then
echo "❌ BLT_API_URL is not set. Refusing to call production API by default. Please set BLT_API_URL in repository secrets/variables."
exit 1
fi
api_url="${{ secrets.BLT_API_URL }}"

Copilot uses AI. Check for mistakes.

Mustafa Tag Eldeen added 2 commits October 15, 2025 05:26
…w feedback

Addresses feedback from:
- @DonnieBLT: 'Please add the logic to process the payment'
- CodeRabbit: URL validation, unused variables, import order, error messages
- Copilot AI: Import placement, UUID for transaction IDs, error specificity

GitHub Sponsors Integration:
- Fetch user's sponsorable ID and available tiers via GraphQL
- Find matching tier for bounty amount (or closest available)
- Create sponsorship via createSponsorship mutation
- Store real sponsorship ID from GitHub API
- Handle GitHub's broken one-time payment API (uses tier-based instead)

Security & Code Quality Fixes:
- Move duplicate payment check BEFORE API call (prevents double charges)
- Add URL validation with regex for owner/repo/issue_number
- Move imports to top of file (SocialAccount, uuid)
- Make sponsor username configurable via GITHUB_SPONSOR_USERNAME setting
- Use UUID instead of timestamp for better uniqueness
- Improve error messages with specific details
- Remove unused exception variable
- Fix import order for isort compliance
- Add comprehensive error handling for all scenarios

Technical Details:
- Uses GitHub GraphQL API (not REST)
- Validates user has GitHub Sponsors enabled
- Checks for existing tiers before payment
- Handles network errors and API failures gracefully
- Logs all operations for debugging

Requirements:
- GITHUB_TOKEN with user/read:org scopes
- Recipients must have GitHub Sponsors with tiers configured
- GITHUB_SPONSOR_USERNAME setting (defaults to DonnieBLT)
- BLT_API_TOKEN for webhook authentication (optional)

Known Limitations:
- GitHub one-time payment API is broken (documented issue)
- Uses recurring sponsorships (can be cancelled manually)
- Requires matching or closest tier amount
- No sandbox mode (creates real sponsorships)
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
website/views/organization.py (1)

3826-3859: Block payout when the issue is still open.

We still skip any state check, so a misfired Action (or a crafted request) can pay bounties on open issues. Please bail out unless issue_data.get("state") == "closed" (and ideally confirm the linked PR merged) before touching payments. This was called out earlier and remains unresolved — let’s close it out now.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Knowledge base: Disabled due to Reviews -> Disable Knowledge Base setting

📥 Commits

Reviewing files that changed from the base of the PR and between b227de1 and 670b74a.

📒 Files selected for processing (3)
  • PR_DESCRIPTION.md (1 hunks)
  • blt/urls.py (2 hunks)
  • website/views/organization.py (3 hunks)
🧰 Additional context used
🪛 LanguageTool
PR_DESCRIPTION.md

[grammar] ~9-~9: There might be a mistake here.
Context: ... Sponsors API. ## Changes ### Database - Added preferred_payment_method field t...

(QB_NEW_EN)


[grammar] ~13-~13: There might be a mistake here.
Context: ...rred_payment_method.py ### Backend API - Created new endpoint/api/bounty_payout...

(QB_NEW_EN)


[grammar] ~14-~14: There might be a mistake here.
Context: ...d new endpoint /api/bounty_payout/ in website/views/organization.py - Accepts POST requests with issue URL and...

(QB_NEW_EN)


[grammar] ~15-~15: There might be a mistake here.
Context: ...uests with issue URL and optional PR URL - Validates issue has bounty label and ass...

(QB_NEW_EN)


[grammar] ~16-~16: There might be a mistake here.
Context: ...ates issue has bounty label and assignee - Checks if user exists in BLT with linked...

(QB_NEW_EN)


[grammar] ~17-~17: There might be a mistake here.
Context: ...exists in BLT with linked GitHub account - Prevents duplicate payments by checking ...

(QB_NEW_EN)


[grammar] ~18-~18: There might be a mistake here.
Context: ...nts by checking existing transaction IDs - Returns appropriate error messages for v...

(QB_NEW_EN)


[grammar] ~19-~19: There might be a mistake here.
Context: ...error messages for various failure cases - Added optional authentication via `X-BLT...

(QB_NEW_EN)


[grammar] ~20-~20: There might be a mistake here.
Context: ...hentication via X-BLT-API-TOKEN header - Added URL route in blt/urls.py ### Gi...

(QB_NEW_EN)


[grammar] ~35-~35: There might be a mistake here.
Context: ...verifies the assignee has a BLT account with connected GitHub profile, checks their ...

(QB_NEW_EN)


[grammar] ~39-~39: There might be a mistake here.
Context: ...rsion addresses several critical issues: - Uses curl for API calls instead of githu...

(QB_NEW_EN)


[uncategorized] ~40-~40: The official name of this software platform is spelled with a capital “H”.
Context: ...s: - Uses curl for API calls instead of github.request() which only works for GitHub's...

(GITHUB)


[grammar] ~50-~50: There might be a mistake here.
Context: ...ntation will be added in future work. - Only GitHub Sponsors payment method is curre...

(QB_NEW_EN)


[grammar] ~52-~52: There might be a mistake here.
Context: ... - Workflow API URL needs to be updated to production URL before deployment. ## T...

(QB_NEW_EN)


[grammar] ~56-~56: There might be a mistake here.
Context: ... Migration dependency has been verified against latest migration (0246). Error handling...

(QB_NEW_EN)


[grammar] ~62-~62: There might be a mistake here.
Context: ... ## Deployment Notes 1. Run migration: python manage.py migrate 2. Add BLT_API_TOKEN to Django settings 3. ...

(QB_NEW_EN)

🪛 Ruff (0.14.0)
website/views/organization.py

3999-3999: Redundant exception object included in logging.exception call

(TRY401)


4070-4070: Redundant exception object included in logging.exception call

(TRY401)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Run Tests
  • GitHub Check: docker-test

Comment on lines +3831 to +3839
for label in issue_data.get("labels", []):
label_name = label.get("name", "")
if label_name.startswith("$"):
has_bounty = True
# Extract amount from label (e.g., "$5" -> 5)
try:
bounty_amount = int(label_name.replace("$", "").strip())
except ValueError:
bounty_amount = 5 # Default to $5
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Robustly parse the bounty amount label.

Labels like “$50 – medium” throw a ValueError, causing us to underpay every such bounty as $5. Extract the leading number instead of defaulting. Suggested fix:

-        for label in issue_data.get("labels", []):
-            label_name = label.get("name", "")
-            if label_name.startswith("$"):
-                has_bounty = True
-                try:
-                    bounty_amount = int(label_name.replace("$", "").strip())
-                except ValueError:
-                    bounty_amount = 5  # Default to $5
-                break
+        amount_pattern = re.compile(r"^\$?\s*(\d+)")
+        for label in issue_data.get("labels", []):
+            label_name = label.get("name", "")
+            match = amount_pattern.match(label_name)
+            if match:
+                has_bounty = True
+                bounty_amount = int(match.group(1))
+                break
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for label in issue_data.get("labels", []):
label_name = label.get("name", "")
if label_name.startswith("$"):
has_bounty = True
# Extract amount from label (e.g., "$5" -> 5)
try:
bounty_amount = int(label_name.replace("$", "").strip())
except ValueError:
bounty_amount = 5 # Default to $5
amount_pattern = re.compile(r"^\$?\s*(\d+)")
for label in issue_data.get("labels", []):
label_name = label.get("name", "")
match = amount_pattern.match(label_name)
if match:
has_bounty = True
bounty_amount = int(match.group(1))
break
🤖 Prompt for AI Agents
In website/views/organization.py around lines 3831 to 3839, the current code
replaces "$" and casts the whole label to int which raises ValueError for labels
like "$50 – medium" and causes fallback to $5; change the parsing to extract the
leading numeric portion (e.g., via a regex that finds the first contiguous
digits after the optional "$"), convert that matched digits string to int, and
if no digits are found or conversion fails fall back to 5; ensure you trim
whitespace and ignore any non-numeric suffixes so labels like "$50 – medium" or
"$1,000 reward" correctly parse as 50 and 1000 respectively (handle commas by
removing them before int conversion).

Comment on lines +3975 to +3988
tiers = sponsors_listing.get("tiers", {}).get("nodes", [])
target_amount_cents = bounty_amount * 100

# Find exact match or closest tier
matching_tier = None
for tier in tiers:
if tier.get("monthlyPriceInCents") == target_amount_cents:
matching_tier = tier
break

# If no exact match, find closest tier
if not matching_tier and tiers:
matching_tier = min(tiers, key=lambda t: abs(t.get("monthlyPriceInCents", 0) - target_amount_cents))
logger.warning(f"No exact tier match for ${bounty_amount}, using closest: ${matching_tier.get('monthlyPriceInCents', 0) / 100}")
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Never downgrade the bounty when picking a Sponsors tier.

min(..., abs(diff)) will happily choose a lower-priced tier than the bounty amount (e.g., paying $5 for a $25 bounty). Require an exact match or the next tier up, and fail fast if that still underpays; don’t silently lower the payout.

🤖 Prompt for AI Agents
In website/views/organization.py around lines 3975 to 3988, the current logic
uses min(tiers, key=abs(diff)) which can select a lower-priced sponsors tier and
thus silently reduce the bounty; change it to require an exact match or the next
tier up: first search for an exact monthlyPriceInCents == target_amount_cents,
then if not found sort tiers by monthlyPriceInCents ascending and pick the first
tier with monthlyPriceInCents >= target_amount_cents; if no such tier exists or
the selected tier still pays less than target_amount_cents raise/return an error
(fail fast) instead of selecting a lower-priced tier, and log an appropriate
error before failing.

Comment on lines +4007 to +4074
mutation($sponsorableId: ID!, $tierId: ID!) {
createSponsorship(input: {
sponsorableId: $sponsorableId
tierId: $tierId
privacyLevel: PUBLIC
}) {
sponsorship {
id
tier {
monthlyPriceInCents
name
}
createdAt
}
}
}
"""

graphql_variables = {
"sponsorableId": sponsorable_id,
"tierId": tier_id,
}

try:
graphql_response = requests.post(
graphql_url,
json={"query": graphql_mutation, "variables": graphql_variables},
headers=graphql_headers,
timeout=30
)

if graphql_response.status_code != 200:
logger.error(f"GitHub Sponsors API returned status {graphql_response.status_code}: {graphql_response.text}")
return JsonResponse({
"success": False,
"error": f"Failed to process GitHub Sponsors payment (HTTP {graphql_response.status_code}). The user may not have GitHub Sponsors enabled."
}, status=400)

graphql_data = graphql_response.json()

# Check for GraphQL errors
if "errors" in graphql_data:
error_messages = [err.get("message", "Unknown error") for err in graphql_data["errors"]]
logger.error(f"GitHub Sponsors GraphQL errors: {error_messages}")
return JsonResponse({
"success": False,
"error": f"GitHub Sponsors API error: {'; '.join(error_messages)}"
}, status=400)

# Extract sponsorship ID from response
sponsorship_data = graphql_data.get("data", {}).get("createSponsorship", {}).get("sponsorship", {})
sponsorship_id = sponsorship_data.get("id")

if not sponsorship_id:
logger.error(f"No sponsorship ID in response: {graphql_data}")
return JsonResponse({
"success": False,
"error": "Failed to create sponsorship. Please ensure the recipient has GitHub Sponsors enabled."
}, status=400)

logger.info(f"Successfully created GitHub Sponsors payment: {sponsorship_id}")

except requests.exceptions.RequestException as e:
logger.exception(f"Network error calling GitHub Sponsors API: {e}")
return JsonResponse({
"success": False,
"error": "Network error while processing payment. Please try again later."
}, status=500)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Creating a GitHub Sponsors payout here opens a recurring subscription.

createSponsorship creates a monthly sponsorship that keeps billing until explicitly cancelled. Calling it for every bounty will enroll the sponsor in recurring charges for each issue — that’s a catastrophic correctness bug. Until we have a safe one-time payout flow (or an immediate cancel step), we must not invoke this mutation. Revert to the placeholder/record-only behaviour or add proper cancellation logic and guardrails.

🧰 Tools
🪛 Ruff (0.14.0)

4070-4070: Redundant exception object included in logging.exception call

(TRY401)

🤖 Prompt for AI Agents
website/views/organization.py lines 4007-4074: the code currently calls GitHub's
createSponsorship GraphQL mutation which creates a recurring monthly
subscription for the sponsor; stop invoking that mutation. Revert this flow to a
record-only / placeholder behavior: remove the external requests.post that
executes createSponsorship, instead persist a local "intended_payout" record (or
update existing placeholder logic) with sponsorable_id, tier_id, amount, and
metadata; return a safe JsonResponse indicating the payout was recorded and
requires manual or deferred processing. If you must enable an automated flow,
gate the external mutation behind a strict feature flag and implement an
immediate cancellation step (call the appropriate GitHub cancellation mutation),
idempotency checks, confirmation/consent UI, and tests — otherwise do not call
GitHub and do not create subscriptions.

Copy link
Collaborator

@DonnieBLT DonnieBLT left a comment

Choose a reason for hiding this comment

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

Please address comments

Copy link
Collaborator

Choose a reason for hiding this comment

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

Delete

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (5)
website/views/organization.py (5)

3749-3761: Add @csrf_exempt decorator to allow GitHub Actions to call this endpoint.

This endpoint is called by a GitHub Action workflow that cannot provide a CSRF token. Without @csrf_exempt, all requests will fail with 403 Forbidden due to Django's CSRF protection.

Apply this diff:

+from django.views.decorators.csrf import csrf_exempt
+
 @require_POST
+@csrf_exempt
 def process_bounty_payout(request):
     """
     API endpoint to process bounty payouts for merged PRs with attached issues.

3826-3846: Verify that the issue is closed before processing payment.

After fetching the issue data, the code immediately proceeds to validate the bounty and process payment without checking if the issue is actually closed. A misfired workflow or crafted request could mark a bounty as paid while the issue is still open.

Apply this diff after line 3826:

 issue_data = response.json()
+
+# Verify issue is closed before processing payment
+if issue_data.get("state") != "closed":
+    return JsonResponse(
+        {"success": False, "error": "Issue must be closed before bounty payout"},
+        status=400
+    )
 
 # Check if issue has dollar tag (bounty)

3831-3840: Fix bounty label parsing to handle common label formats.

The current parsing will fail on labels like "$50 – medium" or "$1,000 reward" and incorrectly default to $5, potentially underpaying bounties. Extract the leading numeric portion instead.

Apply this diff:

+import re
+
 # Check if issue has dollar tag (bounty)
 has_bounty = False
 bounty_amount = 0
+amount_pattern = re.compile(r"^\$?\s*(\d+)")
 for label in issue_data.get("labels", []):
     label_name = label.get("name", "")
-    if label_name.startswith("$"):
+    match = amount_pattern.match(label_name)
+    if match:
         has_bounty = True
-        # Extract amount from label (e.g., "$5" -> 5)
-        try:
-            bounty_amount = int(label_name.replace("$", "").strip())
-        except ValueError:
-            bounty_amount = 5  # Default to $5
+        bounty_amount = int(match.group(1))
         break

3974-3994: Fix tier selection to never downgrade the bounty amount.

The current logic using min(..., key=abs(diff)) can select a tier LOWER than the bounty amount (e.g., paying $5 for a $25 bounty). Require an exact match or the next tier up, and fail if no suitable tier exists.

Apply this diff:

 # Find exact match or closest tier
 matching_tier = None
 for tier in tiers:
     if tier.get("monthlyPriceInCents") == target_amount_cents:
         matching_tier = tier
         break
 
-# If no exact match, find closest tier
+# If no exact match, find next tier up (never downgrade)
 if not matching_tier and tiers:
-    matching_tier = min(tiers, key=lambda t: abs(t.get("monthlyPriceInCents", 0) - target_amount_cents))
-    logger.warning(f"No exact tier match for ${bounty_amount}, using closest: ${matching_tier.get('monthlyPriceInCents', 0) / 100}")
+    # Sort tiers by price ascending and find first tier >= target
+    sorted_tiers = sorted(tiers, key=lambda t: t.get("monthlyPriceInCents", 0))
+    for tier in sorted_tiers:
+        tier_amount = tier.get("monthlyPriceInCents", 0)
+        if tier_amount >= target_amount_cents:
+            matching_tier = tier
+            logger.warning(f"No exact tier match for ${bounty_amount}, using next tier up: ${tier_amount / 100}")
+            break
 
 if not matching_tier:
     return JsonResponse({
         "success": False,
-        "error": f"No sponsorship tiers available for {assignee_username}. They need to set up sponsor tiers first."
+        "error": f"No suitable sponsorship tier found for ${bounty_amount}. User needs a tier at or above this amount."
     }, status=400)

4005-4074: CRITICAL: createSponsorship creates a recurring subscription, not a one-time payment.

Calling createSponsorship for every bounty creates a recurring monthly subscription that charges the sponsor indefinitely until explicitly cancelled. This is catastrophic: if 10 bounties are paid, the sponsor is enrolled in 10 recurring monthly subscriptions that will continue charging forever.

This mutation must NOT be invoked for bounty payments. Revert to a placeholder/record-only flow until a safe one-time payout mechanism is implemented, or add immediate cancellation logic, idempotency checks, and proper safeguards.

Apply this diff:

-        # Step 3: Create sponsorship
-        graphql_mutation = """
-        mutation($sponsorableId: ID!, $tierId: ID!) {
-          createSponsorship(input: {
-            sponsorableId: $sponsorableId
-            tierId: $tierId
-            privacyLevel: PUBLIC
-          }) {
-            sponsorship {
-              id
-              tier {
-                monthlyPriceInCents
-                name
-              }
-              createdAt
-            }
-          }
-        }
-        """
-        
-        graphql_variables = {
-            "sponsorableId": sponsorable_id,
-            "tierId": tier_id,
-        }
-        
-        try:
-            graphql_response = requests.post(
-                graphql_url,
-                json={"query": graphql_mutation, "variables": graphql_variables},
-                headers=graphql_headers,
-                timeout=30
-            )
-            
-            if graphql_response.status_code != 200:
-                logger.error(f"GitHub Sponsors API returned status {graphql_response.status_code}: {graphql_response.text}")
-                return JsonResponse({
-                    "success": False,
-                    "error": f"Failed to process GitHub Sponsors payment (HTTP {graphql_response.status_code}). The user may not have GitHub Sponsors enabled."
-                }, status=400)
-            
-            graphql_data = graphql_response.json()
-            
-            # Check for GraphQL errors
-            if "errors" in graphql_data:
-                error_messages = [err.get("message", "Unknown error") for err in graphql_data["errors"]]
-                logger.error(f"GitHub Sponsors GraphQL errors: {error_messages}")
-                return JsonResponse({
-                    "success": False,
-                    "error": f"GitHub Sponsors API error: {'; '.join(error_messages)}"
-                }, status=400)
-            
-            # Extract sponsorship ID from response
-            sponsorship_data = graphql_data.get("data", {}).get("createSponsorship", {}).get("sponsorship", {})
-            sponsorship_id = sponsorship_data.get("id")
-            
-            if not sponsorship_id:
-                logger.error(f"No sponsorship ID in response: {graphql_data}")
-                return JsonResponse({
-                    "success": False,
-                    "error": "Failed to create sponsorship. Please ensure the recipient has GitHub Sponsors enabled."
-                }, status=400)
-            
-            logger.info(f"Successfully created GitHub Sponsors payment: {sponsorship_id}")
-            
-        except requests.exceptions.RequestException as e:
-            logger.exception(f"Network error calling GitHub Sponsors API: {e}")
-            return JsonResponse({
-                "success": False,
-                "error": "Network error while processing payment. Please try again later."
-            }, status=500)
+        # TODO: Implement safe one-time payment flow
+        # For now, use placeholder transaction ID pending full implementation
+        sponsorship_id = f"pending_{assignee_username}_{int(time.time())}"
+        
+        logger.warning(f"Using placeholder sponsorship ID: {sponsorship_id}. Full GitHub Sponsors integration pending.")
🧹 Nitpick comments (1)
website/views/organization.py (1)

3999-3999: Remove redundant exception object from logging.exception calls.

logger.exception() automatically logs the exception traceback, so passing the exception object explicitly is redundant.

Apply this diff:

-            logger.exception(f"Network error fetching user info: {e}")
+            logger.exception("Network error fetching user info")
-            logger.exception(f"Network error calling GitHub Sponsors API: {e}")
+            logger.exception("Network error calling GitHub Sponsors API")

Also applies to: 4070-4070

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Knowledge base: Disabled due to Reviews -> Disable Knowledge Base setting

📥 Commits

Reviewing files that changed from the base of the PR and between 670b74a and 4c07b18.

📒 Files selected for processing (1)
  • website/views/organization.py (2 hunks)
🧰 Additional context used
🪛 Ruff (0.14.0)
website/views/organization.py

3999-3999: Redundant exception object included in logging.exception call

(TRY401)


4070-4070: Redundant exception object included in logging.exception call

(TRY401)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests

@TAGOOZ
Copy link
Author

TAGOOZ commented Oct 15, 2025

@DonnieBLT

Thanks for the feedback. I propose two options:

Option 1: Revert to Safe Placeholder (Recommended)

  • Automates PR merge detection, user validation, bounty tracking
  • Does NOT call GitHub Sponsors API (no recurring payments)
  • Admins handle payments manually
  • Zero risk of payment errors or bugs
  • Can be tested safely without real money
  • Maintains most automation benefits without risks

Option 2: Add Immediate Cancellation Logic (Risky)

  • Calls createSponsorship then tries to cancel instantly
  • High risk of recurring charges if cancellation fails
  • Complex rollback and error handling needed
  • GitHub’s one-time payment API is currently broken and doesn't support one time payment (https://github.com/orgs/community/discussions/138161))
  • Risky with real money involved and hard to test

Recommendation

I strongly recommend Option 1 for safety, reliability, and ease of testing while keeping admin control over payouts.

Would you like me to proceed with Option 1? If you prefer Option 2, I can implement it with precautions.

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

Labels

None yet

Projects

Status: Ready

2 participants