Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/auto-bounty-payout.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Auto Bounty Payout

on:
pull_request:
types: [closed]

jobs:
process-bounty:
# Only run if PR was merged (not just closed)
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest

steps:
- name: Extract linked issues from PR
id: extract_issues
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pr_number="${{ github.event.pull_request.number }}"
pr_body="${{ github.event.pull_request.body }}"
echo "PR #$pr_number was merged"
echo "PR Body: $pr_body"
# Extract issue numbers from PR body
# Looks for patterns like "Fixes #123", "Closes #456", "Resolves #789"
issue_numbers=$(echo "$pr_body" | grep -oiE "(close[sd]?|fix(e[sd])?|resolve[sd]?) #[0-9]+" | grep -oE "#[0-9]+" | sed 's/#//' || echo "")
if [ -z "$issue_numbers" ]; then
echo "No linked issues found in PR body"
echo "has_issues=false" >> $GITHUB_OUTPUT
else
echo "Found linked issues: $issue_numbers"
echo "has_issues=true" >> $GITHUB_OUTPUT
# Convert to JSON array
issue_array=$(echo "$issue_numbers" | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "issue_numbers=$issue_array" >> $GITHUB_OUTPUT
fi
- name: Check for bounty labels and process payment
if: steps.extract_issues.outputs.has_issues == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBERS: ${{ steps.extract_issues.outputs.issue_numbers }}
run: |
echo "Processing issues: $ISSUE_NUMBERS"
# Parse JSON array
for issue_num in $(echo "$ISSUE_NUMBERS" | jq -r '.[]'); do
echo "Checking issue #$issue_num for bounty labels..."
# Get issue details
issue_data=$(gh api repos/${{ github.repository }}/issues/$issue_num)
# Check if issue has a dollar label (bounty)
has_bounty=$(echo "$issue_data" | jq -r '.labels[] | select(.name | startswith("$")) | .name' | head -n 1)
if [ -n "$has_bounty" ]; then
echo "Issue #$issue_num has bounty label: $has_bounty"
# Construct issue URL
issue_url="https://github.com/${{ github.repository }}/issues/$issue_num"
pr_url="${{ github.event.pull_request.html_url }}"
echo "Sending bounty payout request to BLT API..."
# Call the BLT bounty payout API
# Note: Replace with actual BLT production URL
# Add BLT_API_TOKEN to repository secrets for authentication
response=$(curl -X POST \
-H "Content-Type: application/json" \
-H "X-BLT-API-TOKEN: ${{ secrets.BLT_API_TOKEN }}" \
-d "{\"issue_url\": \"$issue_url\", \"pr_url\": \"$pr_url\"}" \
https://blt.owasp.org/api/bounty_payout/ \
-w "\n%{http_code}" -s)
http_code=$(echo "$response" | tail -n 1)
response_body=$(echo "$response" | head -n -1)
echo "API Response (HTTP $http_code): $response_body"
if [ "$http_code" -eq 200 ]; then
echo "✅ Successfully initiated bounty payout for issue #$issue_num"
# Add a comment to the issue
gh issue comment $issue_num --body "🎉 Bounty payout has been initiated for this issue! The payment will be processed via GitHub Sponsors."
else
echo "⚠️ Failed to process bounty payout for issue #$issue_num"
echo "Response: $response_body"
# Add a comment about the failure
gh issue comment $issue_num --body "⚠️ Automated bounty payout failed. Please contact an administrator. Error: $response_body"
fi
else
echo "Issue #$issue_num does not have a bounty label, skipping..."
fi
done
- name: Summary
if: always()
run: |
echo "Auto bounty payout workflow completed"
echo "PR: ${{ github.event.pull_request.html_url }}"
echo "Merged by: ${{ github.event.pull_request.merged_by.login }}"
2 changes: 2 additions & 0 deletions blt/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@
ReportIpView,
RoomCreateView,
RoomsListView,
process_bounty_payout,
ScoreboardView,
TimeLogListAPIView,
TimeLogListView,
Expand Down Expand Up @@ -642,6 +643,7 @@
re_path(r"^hunt/$", login_required(HuntCreate.as_view()), name="hunt"),
re_path(r"^bounties/$", Listbounties.as_view(), name="hunts"),
path("bounties/payouts/", BountyPayoutsView.as_view(), name="bounty_payouts"),
path("api/bounty_payout/", csrf_exempt(process_bounty_payout), name="process_bounty_payout"),
path("api/load-more-issues/", load_more_issues, name="load_more_issues"),
re_path(r"^invite/$", InviteCreate.as_view(template_name="invite.html"), name="invite"),
re_path(r"^terms/$", TemplateView.as_view(template_name="terms.html"), name="terms"),
Expand Down
25 changes: 25 additions & 0 deletions website/migrations/0247_userprofile_preferred_payment_method.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated migration for preferred_payment_method field

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('website', '0246_add_user_progress_models'),
]

operations = [
migrations.AddField(
model_name='userprofile',
name='preferred_payment_method',
field=models.CharField(
blank=True,
choices=[('sponsors', 'GitHub Sponsors'), ('bch', 'Bitcoin Cash')],
default='sponsors',
help_text='Preferred payment method for bounty payouts',
max_length=20,
null=True
),
),
]
8 changes: 8 additions & 0 deletions website/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,14 @@ class UserProfile(models.Model):
btc_address = models.CharField(max_length=100, blank=True, null=True, validators=[validate_btc_address])
bch_address = models.CharField(max_length=100, blank=True, null=True, validators=[validate_bch_address])
eth_address = models.CharField(max_length=100, blank=True, null=True)
preferred_payment_method = models.CharField(
max_length=20,
choices=[("sponsors", "GitHub Sponsors"), ("bch", "Bitcoin Cash")],
default="sponsors",
blank=True,
null=True,
help_text="Preferred payment method for bounty payouts",
)
created = models.DateTimeField(auto_now_add=True)
tags = models.ManyToManyField(Tag, blank=True)
x_username = models.CharField(max_length=50, blank=True, null=True)
Expand Down
209 changes: 209 additions & 0 deletions website/views/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -3742,3 +3742,212 @@
except Exception as e:
logger.error(f"Error extracting payment info from comments for issue #{issue_number}: {str(e)}")
return False


@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(f"Invalid API token provided for bounty payout")
return JsonResponse(
{"success": False, "error": "Invalid API token"},
status=401
)

# Parse request data
data = json.loads(request.body) if request.body else {}
issue_url = data.get("issue_url")
pr_url = data.get("pr_url", "")

if not issue_url:
return JsonResponse(
{"success": False, "error": "issue_url is required"},
status=400
)

logger.info(f"Processing bounty payout for issue: {issue_url}, PR: {pr_url}")

# Extract issue details from URL
# URL format: https://github.com/owner/repo/issues/number
parts = issue_url.rstrip("/").split("/")
if len(parts) < 7 or parts[5] != "issues":
return JsonResponse(
{"success": False, "error": "Invalid issue URL format"},
status=400
)

owner = parts[3]
repo_name = parts[4]
issue_number = parts[6]

# Fetch issue 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}/issues/{issue_number}"
response = requests.get(api_url, headers=headers, timeout=10)

if response.status_code != 200:
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.
status=400
)

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
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.

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

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.
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)
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

# 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.
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,
})

except Exception as e:
logger.error(f"Error processing bounty payout: {str(e)}")
return JsonResponse(
{"success": False, "error": str(e)},
status=500
)
Loading