Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cd28fee
Added nest app and slack command for sponsor
abhayymishraa Feb 27, 2025
e7f54b1
Added test for sponsership model
abhayymishraa Feb 27, 2025
7e81614
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Feb 27, 2025
faf9968
Resolved as per suggestions
abhayymishraa Mar 1, 2025
c448539
Resolved cr suggestions
abhayymishraa Mar 1, 2025
00da50e
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 1, 2025
c6c3ce7
Added suggestions by cr
abhayymishraa Mar 1, 2025
b17a378
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 2, 2025
a5ac816
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 3, 2025
482ec15
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 7, 2025
d8cc69e
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 13, 2025
f062c55
Merge branch 'main' into feat/sponsorship-program
arkid15r Mar 15, 2025
2949848
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 15, 2025
763cf8b
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 16, 2025
956c164
resolved suggestions
abhayymishraa Mar 16, 2025
ebcf410
resolved test
abhayymishraa Mar 16, 2025
78359aa
resolved conflict
abhayymishraa Mar 18, 2025
c272978
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 18, 2025
b09daae
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 20, 2025
19644b7
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Mar 24, 2025
d8e444f
Merge branch 'main' into feat/sponsorship-program
abhayymishraa Apr 4, 2025
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
Empty file added backend/apps/nest/__init__.py
Empty file.
11 changes: 11 additions & 0 deletions backend/apps/nest/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.contrib import admin

from apps.nest.models.sponsorship import Sponsorship


class SponsorshipAdmin(admin.ModelAdmin):
list_display = ("issue", "price_usd", "slack_user_id")
search_fields = ("issue__title", "slack_user_id")


admin.site.register(Sponsorship, SponsorshipAdmin)
5 changes: 5 additions & 0 deletions backend/apps/nest/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class NestConfig(AppConfig):
name = "apps.nest"
46 changes: 46 additions & 0 deletions backend/apps/nest/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Generated by Django 5.1.5 on 2025-02-27 05:33

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

dependencies = [
("github", "0016_user_is_bot"),
]

operations = [
migrations.CreateModel(
name="Sponsorship",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("nest_created_at", models.DateTimeField(auto_now_add=True)),
("nest_updated_at", models.DateTimeField(auto_now=True)),
("deadline_at", models.DateTimeField(blank=True, null=True)),
("price_usd", models.FloatField()),
("slack_user_id", models.CharField(max_length=100)),
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Validate the slack_user_id field.

Consider adding validation for the slack_user_id field format to ensure it matches Slack's user ID pattern.


🏁 Script executed:

#!/bin/bash
# Check if the Sponsorship model has validation for slack_user_id in the model definition

# Find the Sponsorship model definition
fd -e py -t f "sponsorship.py" | xargs grep -A 20 "class Sponsorship"

Length of output: 686


Action Required: Implement Slack User ID Validation

The Sponsorship model currently defines the slack_user_id field without any validation (see sponsorship.py), so the field does not enforce Slack’s expected user ID format. To avoid potential data quality issues, please add appropriate validation (for example, using Django's RegexValidator with a pattern matching Slack user IDs, such as one starting with "U" followed by alphanumeric characters). Note that changes should be made to the model definition; migration files will be updated accordingly in subsequent auto-generated migrations.

(
"issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="sponsorships",
to="github.issue",
),
),
],
options={
"verbose_name_plural": "Sponsorships",
"db_table": "nest_sponsorships",
},
),
]
Empty file.
Empty file.
38 changes: 38 additions & 0 deletions backend/apps/nest/models/sponsorship.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Nest app sponsorship model."""

from django.db import models

from apps.common.models import BulkSaveModel, TimestampedModel
from apps.github.models.issue import Issue


class Sponsorship(BulkSaveModel, TimestampedModel):
"""Sponsorship model."""

deadline_at = models.DateTimeField(null=True, blank=True)
price_usd = models.FloatField()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's rename price_usd to amount and add currency (EUR, USD with USD default)

slack_user_id = models.CharField(max_length=100)

issue = models.ForeignKey(
Issue,
on_delete=models.CASCADE,
related_name="sponsorships",
)

class Meta:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Move to top

db_table = "nest_sponsorships"
verbose_name_plural = "Sponsorships"

def __str__(self):
"""Sponsorship human readable representation."""
return f"Sponsorship for {self.issue.title} by {self.slack_user_id}"

@staticmethod
def update_data(sponsorship, **kwargs):
"""Update sponsorship data with the provided fields."""
fields_to_update = ["price_usd", "deadline_at", "slack_user_id"]
for field in fields_to_update:
if field in kwargs:
setattr(sponsorship, field, kwargs[field])
sponsorship.save()
return sponsorship
130 changes: 125 additions & 5 deletions backend/apps/slack/commands/sponsor.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,124 @@
"""Slack bot sponsors command."""

import logging

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction

from apps.common.constants import NL
from apps.slack.apps import SlackConfig
from apps.slack.blocks import markdown
from apps.slack.utils import get_text
from apps.slack.utils import (
get_or_create_issue,
get_text,
validate_deadline,
validate_github_issue_link,
validate_price,
)

logger = logging.getLogger(__name__)

COMMAND = "/sponsor"

COMMAND_FORMAT_ERROR = (
"Invalid command format. Usage: `/sponsor task add <issue_link> <price_usd> [deadline]`"
)
DATE_INDEX = 4
MIN_PARTS_LENGTH = 4
TIME_INDEX = 5


def sponsor_handler(ack, command, client):
"""Slack /sponsor command handler."""
from apps.nest.models.sponsorship import Sponsorship

ack()

if not settings.SLACK_COMMANDS_ENABLED:
return

blocks = [
markdown(f"Coming soon...{NL}"),
]
def validate_command_format(parts):
if len(parts) < MIN_PARTS_LENGTH:
raise ValidationError(COMMAND_FORMAT_ERROR)

text = command.get("text", "")
if text.startswith("task add"):
try:
parts = text.split()
validate_command_format(parts)

issue_link = parts[2]
price = parts[3]

deadline_str = None
if len(parts) > DATE_INDEX:
deadline_str = parts[DATE_INDEX]
if len(parts) > TIME_INDEX:
deadline_str += " " + parts[TIME_INDEX]

validate_github_issue_link(issue_link)
validated_price = validate_price(price)
deadline = validate_deadline(deadline_str) if deadline_str else None

with transaction.atomic():
issue = get_or_create_issue(issue_link)
sponsorship, created = Sponsorship.objects.get_or_create(
issue=issue,
defaults={
"price_usd": validated_price,
"slack_user_id": command["user_id"],
"deadline_at": deadline,
},
)

if not created:
sponsorship.price_usd = validated_price
sponsorship.slack_user_id = command["user_id"]
sponsorship.deadline_at = deadline
sponsorship.save()

blocks = get_sponsorship_blocks(sponsorship)
except ValidationError as e:
logger.exception("Validation error")
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"❌ *Error:* {e!s}{NL}",
},
}
]
except Exception:
logger.exception("Validation error")
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"❌ *Error:* An error occurred while processing your request.{NL}"
),
},
}
]
else:
usage_text = (
f"*Usage:* `/sponsor task add <issue_link> <price_usd> [deadline]`{NL}"
f"Example: `/sponsor task add https://github.com/ORG/Repo/issues/XYZ"
f"100 2025-12-31`{NL}"
f"Example with time: `/sponsor task add https://github.com/ORG/Repo/"
f"issues/XYZ 100 2025-12-31 23:59`"
)
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": usage_text,
},
}
]

conversation = client.conversations_open(users=command["user_id"])
client.chat_postMessage(
Expand All @@ -31,3 +130,24 @@ def sponsor_handler(ack, command, client):

if SlackConfig.app:
sponsor_handler = SlackConfig.app.command(COMMAND)(sponsor_handler)


def get_sponsorship_blocks(sponsorship):
"""Generate Slack blocks for the sponsorship confirmation message."""
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"🎉 *Sponsorship created successfully!* 🎉{NL}"
f"*Issue:* {sponsorship.issue.title}{NL}"
f"*Price:* ${sponsorship.price_usd}{NL}"
f"*Created by:* <@{sponsorship.slack_user_id}>{NL}",
},
}
]
if sponsorship.deadline_at:
blocks[0]["text"]["text"] += (
f"*Deadline:* {sponsorship.deadline_at.strftime('%Y-%m-%d %H:%M')}{NL}"
)
return blocks
100 changes: 100 additions & 0 deletions backend/apps/slack/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

import logging
import re
from datetime import datetime
from functools import lru_cache
from html import escape as escape_html
from urllib.parse import urljoin

import requests
import yaml
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
from github import Github
from lxml import html
from requests.exceptions import RequestException

Expand All @@ -16,6 +21,19 @@
logger = logging.getLogger(__name__)


ISSUES_INDEX = 5
GITHUB_COM_INDEX = 2
MIN_PARTS_LENGTH = 4

DEADLINE_FORMAT_ERROR = "Invalid deadline format. Use YYYY-MM-DD or YYYY-MM-DD HH:MM."
DEADLINE_FUTURE_ERROR = "Deadline must be in the future."
FETCH_ISSUE_ERROR = "Failed to fetch issue from GitHub: {error}"
INVALID_ISSUE_LINK_FORMAT = "Invalid GitHub issue link format."
ISSUE_LINK_ERROR = "Issue link must belong to an OWASP repository."
PRICE_POSITIVE_ERROR = "Price must be a positive value."
PRICE_VALID_ERROR = "Price must be a valid number."


def escape(content):
"""Escape HTML content."""
return escape_html(content, quote=False)
Expand Down Expand Up @@ -65,6 +83,48 @@ def get_news_data(limit=10, timeout=30):
return items


def get_or_create_issue(issue_link):
Copy link
Collaborator

Choose a reason for hiding this comment

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

This scope is to broad for /sponsor command's function. Let's split it and remove from here.

"""Fetch or create an Issue instance from the GitHub API."""
from apps.github.models.issue import Issue
from apps.github.models.repository import Repository
from apps.github.models.user import User

logger.info("Fetching or creating issue for link: %s", issue_link)

# Extract repository owner, repo name, and issue number from the issue link
# Example: https://github.com/OWASP/Nest/issues/XYZ
parts = issue_link.strip("/").split("/")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use urllib.parse.urlparse for this.

if (
len(parts) < MIN_PARTS_LENGTH
or parts[GITHUB_COM_INDEX] != "github.com"
or parts[ISSUES_INDEX] != "issues"
):
raise ValidationError(INVALID_ISSUE_LINK_FORMAT)

try:
return Issue.objects.get(url=issue_link)
except Issue.DoesNotExist:
pass

github_client = Github(settings.GITHUB_TOKEN)
issue_number = int(parts[6])
owner = parts[3]
repo_name = parts[4]

try:
# Fetch the repository and issue from GitHub
gh_repo = github_client.get_repo(f"{owner}/{repo_name}")
gh_issue = gh_repo.get_issue(issue_number)
repository = Repository.objects.get(name=repo_name)
author = User.objects.get(login=gh_issue.user.login)
Copy link
Collaborator

Choose a reason for hiding this comment

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

These may not exist yet.

Copy link
Collaborator Author

@abhayymishraa abhayymishraa Feb 28, 2025

Choose a reason for hiding this comment

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

yes what did you suggest create user and repository too ?(if it not exist in the database?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, we should create them first.


# Update or create the issue in the database
return Issue.update_data(gh_issue, author=author, repository=repository)
except Exception as e:
logger.exception("Failed to fetch issue from GitHub: %s")
raise ValidationError(FETCH_ISSUE_ERROR.format(error=e)) from e
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should be a part of github.common.py sync_issue()



@lru_cache
def get_staff_data(timeout=30):
"""Get staff data."""
Expand Down Expand Up @@ -132,3 +192,43 @@ def strip_markdown(text):
"""Strip markdown formatting."""
slack_link_pattern = re.compile(r"<(https?://[^|]+)\|([^>]+)>")
return slack_link_pattern.sub(r"\2 (\1)", text).replace("*", "")


def validate_deadline(deadline_str):
"""Validate that the deadline is in a valid datetime format."""
try:
# Try parsing the deadline in YYYY-MM-DD format
deadline = datetime.strptime(deadline_str, "%Y-%m-%d").replace(
tzinfo=timezone.get_current_timezone()
)
except ValueError:
try:
# Try parsing the deadline in YYYY-MM-DD HH:MM format
deadline = datetime.strptime(deadline_str, "%Y-%m-%d %H:%M").replace(
tzinfo=timezone.get_current_timezone()
)
except ValueError as e:
raise ValidationError(DEADLINE_FORMAT_ERROR) from e

if deadline < timezone.now():
raise ValidationError(DEADLINE_FUTURE_ERROR)

return deadline


def validate_github_issue_link(issue_link):
"""Validate that the issue link belongs to a valid OWASP-related repository."""
if not issue_link.startswith("https://github.com/OWASP"):
raise ValidationError(ISSUE_LINK_ERROR)
return issue_link


def validate_price(price):
"""Validate that the price is a positive float value."""
try:
price = float(price)
if price <= 0:
raise ValidationError(PRICE_POSITIVE_ERROR)
except ValueError as e:
raise ValidationError(PRICE_VALID_ERROR) from e
return price
Copy link
Collaborator

Choose a reason for hiding this comment

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

These validators belong with Snapshot model, definitely not here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Snapshot model ? really?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

i think you meant sponsorship model !!!

Copy link
Collaborator

Choose a reason for hiding this comment

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

You're right.

Loading