Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added nest app and slack command for sponsorship #947

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
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)),
(
"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()
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)
Comment on lines +118 to +119
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
Comment on lines +104 to +125
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
Comment on lines +197 to +234
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