From 20b27f528d6e278fe225492df138d1b1927b96c0 Mon Sep 17 00:00:00 2001 From: SahilDhillon21 Date: Sun, 2 Feb 2025 03:04:54 +0530 Subject: [PATCH 1/4] Basic setup --- blt/urls.py | 3 + website/static/images/sorting-hat.png | Bin 0 -> 49586 bytes website/templates/ossh/home.html | 36 ++++++++++++ website/templates/ossh/results.html | 8 +++ website/utils.py | 80 ++++++++++++++++++++++++++ website/views/ossh.py | 19 ++++++ 6 files changed, 146 insertions(+) create mode 100644 website/static/images/sorting-hat.png create mode 100644 website/templates/ossh/home.html create mode 100644 website/templates/ossh/results.html create mode 100644 website/views/ossh.py diff --git a/blt/urls.py b/blt/urls.py index e21aa10ca..a9c0d970c 100644 --- a/blt/urls.py +++ b/blt/urls.py @@ -170,6 +170,7 @@ view_hunt, weekly_report, ) +from website.views.ossh import ossh_home, ossh_results from website.views.project import ( ProjectBadgeView, ProjectsDetailView, @@ -849,6 +850,8 @@ path("owasp/", TemplateView.as_view(template_name="owasp.html"), name="owasp"), path("batch-send-bacon-tokens/", batch_send_bacon_tokens_view, name="batch_send_bacon_tokens"), path("pending-transactions/", pending_transactions_view, name="pending_transactions"), + path("open-source-sorting-hat/", ossh_home, name="ossh_home"), + path("open-source-sorting-hat/results", ossh_results, name="ossh_results"), ] if settings.DEBUG: diff --git a/website/static/images/sorting-hat.png b/website/static/images/sorting-hat.png new file mode 100644 index 0000000000000000000000000000000000000000..0997c59cd497a374c59b64621e19f90e5ea9c4b8 GIT binary patch literal 49586 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 DSs=$B literal 0 HcmV?d00001 diff --git a/website/templates/ossh/home.html b/website/templates/ossh/home.html new file mode 100644 index 000000000..af0f1a3cc --- /dev/null +++ b/website/templates/ossh/home.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% load static %} +{% block content %} +
+ {% include "includes/sidenav.html" %} +
+
+ +
+
+ {% csrf_token %} +
+ +
+ + + + +
+
+
+ +
+
+
+
+
+
+{% endblock content %} diff --git a/website/templates/ossh/results.html b/website/templates/ossh/results.html new file mode 100644 index 000000000..1f5913019 --- /dev/null +++ b/website/templates/ossh/results.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% load static %} +{% block content %} +
+ {% include "includes/sidenav.html" %} +

Results page

+
+{% endblock content %} diff --git a/website/utils.py b/website/utils.py index f81c53e58..547f5a4f8 100644 --- a/website/utils.py +++ b/website/utils.py @@ -465,3 +465,83 @@ def git_url_to_zip_url(git_url, branch="master"): return zip_url else: raise ValueError("Invalid .git URL provided") + + +import requests + + +def fetch_github_user_data(username): + """Fetches relevant GitHub user data for recommendations.""" + base_url = "https://api.github.com/users/" + repos_url = f"https://api.github.com/users/{username}/repos" + starred_url = f"https://api.github.com/users/{username}/starred" + events_url = f"https://api.github.com/users/{username}/events" + + user_data = {} + + try: + user_response = requests.get(f"{base_url}{username}") + if user_response.status_code == 200: + user_info = user_response.json() + user_data["profile"] = { + "username": user_info.get("login"), + "name": user_info.get("name"), + "bio": user_info.get("bio"), + "location": user_info.get("location"), + "followers": user_info.get("followers"), + "following": user_info.get("following"), + "avatar_url": user_info.get("avatar_url"), + "blog": user_info.get("blog"), + "company": user_info.get("company"), + "twitter": user_info.get("twitter_username"), + "public_repos": user_info.get("public_repos"), + } + + repos_response = requests.get(repos_url) + if repos_response.status_code == 200: + repos = repos_response.json() + user_data["repositories"] = [ + { + "name": repo["name"], + "url": repo["html_url"], + "language": repo["language"], + "stars": repo["stargazers_count"], + "forks": repo["forks_count"], + "description": repo["description"], + } + for repo in repos + ] + + starred_response = requests.get(starred_url) + if starred_response.status_code == 200: + starred = starred_response.json() + user_data["starred_repos"] = [ + { + "name": repo["name"], + "url": repo["html_url"], + "language": repo["language"], + "stars": repo["stargazers_count"], + } + for repo in starred + ] + + events_response = requests.get(events_url) + if events_response.status_code == 200: + events = events_response.json() + user_data["recent_activity"] = [ + { + "type": event["type"], + "repo": event["repo"]["name"], + "created_at": event["created_at"], + } + for event in events + if event["type"] in ["PushEvent", "PullRequestEvent", "IssuesEvent"] + ] + + languages = [repo["language"] for repo in user_data.get("repositories", []) if repo["language"]] + user_data["top_languages"] = list(set(languages)) + + except Exception as e: + user_data["error"] = str(e) + + return user_data diff --git a/website/views/ossh.py b/website/views/ossh.py new file mode 100644 index 000000000..b3c228a57 --- /dev/null +++ b/website/views/ossh.py @@ -0,0 +1,19 @@ +from django.shortcuts import render + +from website.utils import fetch_github_user_data + + +def ossh_home(request): + template = "ossh/home.html" + return render(request, template) + + +def ossh_results(request): + template = "ossh/results.html" + + if request.method == "POST": + github_username = request.POST.get("github-username") + user_data = fetch_github_user_data(github_username) + print(user_data) + context = {"username": github_username, "user_data": user_data} + return render(request, template, context) From 78a3387616c42b999e69dfe92af094d187ee5559 Mon Sep 17 00:00:00 2001 From: SahilDhillon21 Date: Mon, 3 Feb 2025 04:40:43 +0530 Subject: [PATCH 2/4] OpenSourceRepo model and command to fetch repos --- website/admin.py | 2 + website/management/commands/fetch_os_repos.py | 239 ++++++++++++++++++ .../0188_githubreview_opensourcerepo.py | 77 ++++++ website/models.py | 43 ++++ 4 files changed, 361 insertions(+) create mode 100644 website/management/commands/fetch_os_repos.py create mode 100644 website/migrations/0188_githubreview_opensourcerepo.py diff --git a/website/admin.py b/website/admin.py index 3a9e0c759..3cab89233 100644 --- a/website/admin.py +++ b/website/admin.py @@ -24,6 +24,7 @@ Issue, IssueScreenshot, Monitor, + OpenSourceRepo, Organization, OrganizationAdmin, Payment, @@ -500,3 +501,4 @@ class PostAdmin(admin.ModelAdmin): admin.site.register(Trademark) admin.site.register(TrademarkOwner) admin.site.register(GitHubIssue) +admin.site.register(OpenSourceRepo) diff --git a/website/management/commands/fetch_os_repos.py b/website/management/commands/fetch_os_repos.py new file mode 100644 index 000000000..5599559ee --- /dev/null +++ b/website/management/commands/fetch_os_repos.py @@ -0,0 +1,239 @@ +import logging +import time +from datetime import datetime +from datetime import timezone as dt_timezone + +import requests +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone +from django.utils.text import slugify + +from website.models import OpenSourceRepo, Tag + +# ANSI escape codes for colors +COLOR_RED = "\033[91m" +COLOR_GREEN = "\033[92m" +COLOR_YELLOW = "\033[93m" +COLOR_BLUE = "\033[94m" +COLOR_RESET = "\033[0m" + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Fetches and updates open source repository data from GitHub" + + def handle(self, *args, **options): + self.session = requests.Session() + self.session.headers.update( + {"Authorization": f"token {settings.GITHUB_TOKEN}", "Accept": "application/vnd.github.v3+json"} + ) + + self.MIN_STARS = 10 + self.MIN_FORKS = 5 + self.MAX_REPOS = 500 # Could go upwards of 100k in production + self.MIN_CONTRIBUTORS, self.MIN_COMMITS = 5, 20 + self.MAX_DAYS_SINCE_UPDATE = 60 + + logger.info(f"{COLOR_BLUE}Starting the fetch_repositories process.{COLOR_RESET}") + self.fetch_repositories() + logger.info(f"{COLOR_BLUE}Finished the fetch_repositories process.{COLOR_RESET}") + + def has_code_files(self, repo_full_name): + """Check if the repo contains at least one source code file.""" + try: + repo_details = self.session.get(f"https://api.github.com/repos/{repo_full_name}").json() + default_branch = repo_details.get("default_branch", "main") + + response = self.session.get( + f"https://api.github.com/repos/{repo_full_name}/git/trees/{default_branch}?recursive=1" + ) + if response.status_code != 200: + logger.warning( + f"{COLOR_YELLOW}Failed to fetch file tree for {repo_full_name}: {response.status_code}{COLOR_RESET}" + ) + return False + + files = [file["path"] for file in response.json().get("tree", [])] + code_extensions = {".py", ".js", ".java", ".cpp", ".c", ".ts", ".rb", ".go", ".rs", ".swift"} + return any(file.endswith(tuple(code_extensions)) for file in files) + + except Exception as e: + logger.error(f"{COLOR_RED}Error checking code files for {repo_full_name}: {str(e)}{COLOR_RESET}") + return False + + def get_commit_count(self, repo_full_name): + """Fetches the total commit count efficiently.""" + url = f"https://api.github.com/repos/{repo_full_name}/commits?per_page=1" + try: + response = self.session.get(url) + if response.status_code == 200 and response.links.get("last"): + last_page_url = response.links["last"]["url"] + last_page_number = int(last_page_url.split("page=")[-1]) + return last_page_number + elif response.status_code == 200: + return len(response.json()) + else: + return 0 + except Exception as e: + logger.error(f"{COLOR_RED}Error fetching commit count for {repo_full_name}: {str(e)}{COLOR_RESET}") + return 0 + + def get_contributors_count(self, repo_full_name): + """Fetches the number of contributors.""" + url = f"https://api.github.com/repos/{repo_full_name}/contributors?per_page=1&anon=true" + try: + response = self.session.get(url) + if response.status_code == 200 and response.links.get("last"): + last_page_url = response.links["last"]["url"] + last_page_number = int(last_page_url.split("page=")[-1]) + return last_page_number + elif response.status_code == 200: + return len(response.json()) + else: + return 0 + except Exception as e: + logger.error(f"{COLOR_RED}Error fetching contributors for {repo_full_name}: {str(e)}{COLOR_RESET}") + return 0 + + def is_good_repository(self, repo_data): + """Checks for repository quality.""" + failure_messages = [] + + if not self.has_code_files(repo_data["full_name"]): + failure_messages.append(f"{COLOR_RED}No code files found{COLOR_RESET}") + + num_contributors = self.get_contributors_count(repo_data["full_name"]) + if num_contributors < self.MIN_CONTRIBUTORS: + failure_messages.append(f"{COLOR_RED}Contributors < {self.MIN_CONTRIBUTORS}{COLOR_RESET}") + + num_commits = self.get_commit_count(repo_data["full_name"]) + if num_commits < self.MIN_COMMITS: + failure_messages.append(f"{COLOR_RED}Commits < {self.MIN_COMMITS}{COLOR_RESET}") + + last_push_date = datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt_timezone.utc) + days_since_last_push = (datetime.now(dt_timezone.utc) - last_push_date).days + if days_since_last_push > self.MAX_DAYS_SINCE_UPDATE: + failure_messages.append(f"{COLOR_RED}Last push > {self.MAX_DAYS_SINCE_UPDATE} days ago{COLOR_RESET}") + + if repo_data["stargazers_count"] < self.MIN_STARS: + failure_messages.append(f"{COLOR_RED}Stars < {self.MIN_STARS}{COLOR_RESET}") + if repo_data["forks_count"] < self.MIN_FORKS: + failure_messages.append(f"{COLOR_RED}Forks < {self.MIN_FORKS}{COLOR_RESET}") + if repo_data["archived"]: + failure_messages.append(f"{COLOR_RED}Repository is archived{COLOR_RESET}") + if repo_data.get("license") is None: + failure_messages.append(f"{COLOR_RED}No license found{COLOR_RESET}") + if repo_data.get("size", 0) <= 100: + failure_messages.append(f"{COLOR_RED}Size <= 100 KB{COLOR_RESET}") + + if failure_messages: + logger.warning( + f"{COLOR_YELLOW}Repository {repo_data['full_name']} failed checks: {', '.join(failure_messages)}{COLOR_RESET}" + ) + return False + + logger.info(f"{COLOR_GREEN}Repository {repo_data['full_name']} meets all criteria.{COLOR_RESET}") + return True + + def fetch_repositories(self): + query = " ".join( + [ + "is:public", + f"stars:>={self.MIN_STARS}", + f"forks:>={self.MIN_FORKS}", + "archived:false", + "has:license", + "size:>100", + "-topic:awesome", + "-topic:list", + "-topic:resource", + "-topic:resources", + "-topic:questions", + "-topic:cheatsheet", + "-topic:roadmap", + "-topic:guide", + "-topic:collection", + "-topic:interview", + "-topic:coding-interview", + "-topic:notes", + "-topic:tutorials", + ] + ) + page, repos_processed, repos_saved = 1, 0, 0 + + while repos_processed < self.MAX_REPOS: + try: + logger.info(f"{COLOR_BLUE}Fetching repositories from GitHub API (Page {page}).{COLOR_RESET}") + response = self.session.get( + "https://api.github.com/search/repositories", + params={"q": query, "sort": "stars", "order": "desc", "page": page, "per_page": 100}, + ) + + if response.status_code == 403: + logger.warning( + f"{COLOR_YELLOW}Reached GitHub API rate limit. Sleeping for 60 seconds.{COLOR_RESET}" + ) + time.sleep(60) + continue + elif response.status_code != 200: + logger.error(f"{COLOR_RED}Error fetching repositories: {response.status_code}{COLOR_RESET}") + break + + repos = response.json().get("items", []) + if not repos: + logger.info(f"{COLOR_BLUE}No more repositories found. Exiting loop.{COLOR_RESET}") + break + + self.process_repositories(repos) + repos_processed += len(repos) + logger.info(f"{COLOR_BLUE}Processed {repos_processed} repositories so far.{COLOR_RESET}") + page += 1 + time.sleep(1) + except Exception as e: + logger.error(f"{COLOR_RED}Error fetching repositories: {str(e)}{COLOR_RESET}") + time.sleep(5) + + def process_repositories(self, repos): + for repo_data in repos: + try: + if not self.is_good_repository(repo_data): + continue + + with transaction.atomic(): + repo, created = OpenSourceRepo.objects.update_or_create( + url=repo_data["html_url"], + defaults={ + "name": repo_data["name"], + "owner": repo_data["owner"]["login"], + "description": repo_data["description"] or "", + "primary_language": repo_data["language"] or "", + "last_updated": timezone.make_aware( + datetime.strptime(repo_data["updated_at"], "%Y-%m-%dT%H:%M:%SZ"), dt_timezone.utc + ), + "stars": repo_data["stargazers_count"], + "forks": repo_data["forks_count"], + "open_issues": repo_data["open_issues_count"], + "last_pushed": timezone.make_aware( + datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ"), dt_timezone.utc + ), + "license": repo_data.get("license", {}).get("spdx_id", ""), + }, + ) + + if repo_data.get("topics"): + tags = [] + for topic in repo_data["topics"]: + tag_slug = slugify(topic) + tag, _ = Tag.objects.get_or_create(slug=tag_slug, defaults={"name": topic}) + tags.append(tag) + repo.tags.set(tags) + + logger.info( + f"{COLOR_GREEN}{'Created' if created else 'Updated'} repository: {repo.name}{COLOR_RESET}" + ) + except Exception as e: + logger.error(f"{COLOR_RED}Error processing repository {repo_data['full_name']}: {str(e)}{COLOR_RESET}") + continue diff --git a/website/migrations/0188_githubreview_opensourcerepo.py b/website/migrations/0188_githubreview_opensourcerepo.py new file mode 100644 index 000000000..ce45db79b --- /dev/null +++ b/website/migrations/0188_githubreview_opensourcerepo.py @@ -0,0 +1,77 @@ +# Generated by Django 5.1.4 on 2025-02-02 23:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0187_baconearning"), + ] + + operations = [ + migrations.CreateModel( + name="GitHubReview", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("review_id", models.IntegerField(unique=True)), + ("body", models.TextField(blank=True, null=True)), + ("state", models.CharField(max_length=50)), + ("submitted_at", models.DateTimeField()), + ("url", models.URLField()), + ( + "pull_request", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reviews", + to="website.githubissue", + ), + ), + ( + "reviewer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reviews_made", + to="website.userprofile", + ), + ), + ], + ), + migrations.CreateModel( + name="OpenSourceRepo", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("owner", models.CharField(max_length=255)), + ("url", models.URLField()), + ("description", models.TextField(blank=True)), + ("primary_language", models.CharField(max_length=50)), + ("last_updated", models.DateTimeField()), + ("stars", models.PositiveIntegerField(default=0)), + ("forks", models.PositiveIntegerField(default=0)), + ("open_issues", models.PositiveIntegerField(default=0)), + ("last_pushed", models.DateTimeField()), + ("license", models.CharField(blank=True, max_length=100)), + ( + "tags", + models.ManyToManyField(related_name="repositories", to="website.tag"), + ), + ], + ), + ] diff --git a/website/models.py b/website/models.py index 212b0bf29..eb5708a57 100644 --- a/website/models.py +++ b/website/models.py @@ -1438,3 +1438,46 @@ class BaconEarning(models.Model): def __str__(self): return f"{self.user.username} - {self.tokens_earned} Tokens" + + +class GitHubReview(models.Model): + """ + Model to store reviews made by users on pull requests. + """ + + review_id = models.IntegerField(unique=True) + pull_request = models.ForeignKey( + GitHubIssue, + on_delete=models.CASCADE, + related_name="reviews", + ) + reviewer = models.ForeignKey( + UserProfile, + on_delete=models.CASCADE, + related_name="reviews_made", + ) + body = models.TextField(null=True, blank=True) + state = models.CharField(max_length=50) # e.g., "APPROVED", "CHANGES_REQUESTED", "COMMENTED" + submitted_at = models.DateTimeField() + url = models.URLField() + + def __str__(self): + return f"Review #{self.review_id} by {self.reviewer.user.username} on PR #{self.pull_request.issue_id}" + + +class OpenSourceRepo(models.Model): + name = models.CharField(max_length=255) + owner = models.CharField(max_length=255) + url = models.URLField() + description = models.TextField(blank=True) + primary_language = models.CharField(max_length=50) + last_updated = models.DateTimeField() + tags = models.ManyToManyField("Tag", related_name="repositories") + stars = models.PositiveIntegerField(default=0) + forks = models.PositiveIntegerField(default=0) + open_issues = models.PositiveIntegerField(default=0) + last_pushed = models.DateTimeField() + license = models.CharField(max_length=100, blank=True) + + def __str__(self): + return self.name From f718b162ef46dc080a5b13491502818a97d80035 Mon Sep 17 00:00:00 2001 From: SahilDhillon21 Date: Thu, 6 Feb 2025 01:49:48 +0530 Subject: [PATCH 3/4] Use Repo model only and create ossh community model along with reddit data fetching command --- website/admin.py | 4 +- website/management/commands/fetch_os_repos.py | 17 +- .../commands/fetch_reddit_communities.py | 207 ++++++++++++++++++ .../0188_githubreview_opensourcerepo.py | 77 ------- website/models.py | 43 ++-- 5 files changed, 248 insertions(+), 100 deletions(-) create mode 100644 website/management/commands/fetch_reddit_communities.py delete mode 100644 website/migrations/0188_githubreview_opensourcerepo.py diff --git a/website/admin.py b/website/admin.py index 3cab89233..b1ef7d776 100644 --- a/website/admin.py +++ b/website/admin.py @@ -24,9 +24,9 @@ Issue, IssueScreenshot, Monitor, - OpenSourceRepo, Organization, OrganizationAdmin, + OsshCommunity, Payment, Points, Post, @@ -501,4 +501,4 @@ class PostAdmin(admin.ModelAdmin): admin.site.register(Trademark) admin.site.register(TrademarkOwner) admin.site.register(GitHubIssue) -admin.site.register(OpenSourceRepo) +admin.site.register(OsshCommunity) diff --git a/website/management/commands/fetch_os_repos.py b/website/management/commands/fetch_os_repos.py index 5599559ee..890699427 100644 --- a/website/management/commands/fetch_os_repos.py +++ b/website/management/commands/fetch_os_repos.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.utils.text import slugify -from website.models import OpenSourceRepo, Tag +from website.models import Repo, Tag # ANSI escape codes for colors COLOR_RED = "\033[91m" @@ -203,11 +203,10 @@ def process_repositories(self, repos): continue with transaction.atomic(): - repo, created = OpenSourceRepo.objects.update_or_create( - url=repo_data["html_url"], + repo, created = Repo.objects.update_or_create( + repo_url=repo_data["html_url"], defaults={ "name": repo_data["name"], - "owner": repo_data["owner"]["login"], "description": repo_data["description"] or "", "primary_language": repo_data["language"] or "", "last_updated": timezone.make_aware( @@ -216,10 +215,18 @@ def process_repositories(self, repos): "stars": repo_data["stargazers_count"], "forks": repo_data["forks_count"], "open_issues": repo_data["open_issues_count"], - "last_pushed": timezone.make_aware( + "last_commit_date": timezone.make_aware( datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ"), dt_timezone.utc ), "license": repo_data.get("license", {}).get("spdx_id", ""), + "contributor_count": self.get_contributors_count(repo_data["full_name"]), + "commit_count": self.get_commit_count(repo_data["full_name"]), + "size": repo_data.get("size", 0), + "watchers": repo_data.get("watchers_count", 0), + "subscribers_count": repo_data.get("subscribers_count", 0), + "network_count": repo_data.get("network_count", 0), + "closed_issues": repo_data.get("closed_issues_count", 0), + "open_pull_requests": repo_data.get("open_pull_requests_count", 0), }, ) diff --git a/website/management/commands/fetch_reddit_communities.py b/website/management/commands/fetch_reddit_communities.py new file mode 100644 index 000000000..34a87c613 --- /dev/null +++ b/website/management/commands/fetch_reddit_communities.py @@ -0,0 +1,207 @@ +import logging +from time import sleep + +import requests +from django.core.management.base import BaseCommand +from django.utils.text import slugify + +from website.models import OsshCommunity, Tag + +# ANSI escape codes for colors +COLOR_RED = "\033[91m" +COLOR_GREEN = "\033[92m" +COLOR_YELLOW = "\033[93m" +COLOR_BLUE = "\033[94m" +COLOR_RESET = "\033[0m" + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Fetches tech and open source communities from Reddit" + + def __init__(self): + super().__init__() + self.headers = {"User-Agent": "OsshCommunity/1.0"} + self.processed_subreddits = set() + self.MAX_COMMUNITIES = 100 # Control how many subreddits to fetch + # Base topics to seed the search + self.base_topics = [ + "programming", + "coding", + "developers", + "computerscience", + "technology", + "opensource", + "software", + ] + # Keywords to validate if a subreddit is relevant + self.tech_keywords = { + "programming", + "code", + "coding", + "developer", + "software", + "web", + "api", + "database", + "devops", + "cloud", + "opensource", + "github", + "computer", + "tech", + "engineering", + "framework", + "language", + "script", + "algorithm", + "backend", + "frontend", + "fullstack", + "infrastructure", + "security", + "docker", + "kubernetes", + "linux", + "python", + "javascript", + "java", + "rust", + "golang", + "cpp", + "csharp", + } + + def is_tech_related(self, subreddit_data): + """Check if subreddit is tech-related based on description and title""" + text = ( + (subreddit_data.get("description", "") or "").lower() + + (subreddit_data.get("title", "") or "").lower() + + (subreddit_data.get("display_name", "") or "").lower() + ) + return any(keyword in text for keyword in self.tech_keywords) + + def search_subreddits(self, query): + """Search for subreddits using Reddit's search API""" + url = f"https://www.reddit.com/subreddits/search.json?q={query}&limit=100" + try: + response = requests.get(url, headers=self.headers) + response.raise_for_status() + return response.json()["data"]["children"] + except Exception as e: + logger.error(f"{COLOR_RED}Error searching subreddits for query '{query}': {str(e)}{COLOR_RESET}") + return [] + + def get_related_subreddits(self, subreddit_name): + """Get related subreddits from sidebar and wiki""" + url = f"https://www.reddit.com/r/{subreddit_name}/wiki/related.json" + try: + response = requests.get(url, headers=self.headers) + response.raise_for_status() + return response.json()["data"]["content_md"].lower() + except requests.exceptions.HTTPError as e: + if response.status_code == 404: + logger.warning( + f"{COLOR_YELLOW}Wiki not found for r/{subreddit_name}. Skipping related subreddits.{COLOR_RESET}" + ) + elif response.status_code == 403: + logger.warning( + f"{COLOR_YELLOW}Access denied to wiki for r/{subreddit_name}. Skipping related subreddits.{COLOR_RESET}" + ) + else: + logger.error( + f"{COLOR_RED}Failed to fetch related subreddits for r/{subreddit_name}: {str(e)}{COLOR_RESET}" + ) + return "" + except Exception as e: + logger.error( + f"{COLOR_RED}Unexpected error fetching related subreddits for r/{subreddit_name}: {str(e)}{COLOR_RESET}" + ) + return "" + + def save_subreddit(self, data): + """Save subreddit data to database""" + try: + community, created = OsshCommunity.objects.update_or_create( + external_id=f'reddit-{data["id"]}', + defaults={ + "name": data["display_name"], + "description": data.get("description", "")[:500], + "website": f'https://reddit.com/r/{data["display_name"]}', + "source": "Reddit", + "category": "community", + "contributors_count": data["subscribers"], + "metadata": { + "subscriber_count": data["subscribers"], + "active_user_count": data.get("active_user_count", 0), + "created_utc": data["created_utc"], + }, + }, + ) + + # Add tags based on subreddit name and common topics + tags = set([slugify(data["display_name"])]) + for keyword in self.tech_keywords: + if keyword in data.get("description", "").lower(): + tags.add(keyword) + + for tag_name in tags: + try: + # Try to get or create the tag + tag, _ = Tag.objects.get_or_create(slug=slugify(tag_name), defaults={"name": tag_name}) + community.tags.add(tag) + except Exception as e: + # If the slug already exists, fetch the existing tag + logger.warning(f"{COLOR_YELLOW}Tag '{tag_name}' already exists. Using existing tag.{COLOR_RESET}") + tag = Tag.objects.get(slug=slugify(tag_name)) + community.tags.add(tag) + + return created + except Exception as e: + logger.error(f"{COLOR_RED}Failed to save r/{data['display_name']}: {str(e)}{COLOR_RESET}") + return None + + def handle(self, *args, **options): + logger.info(f"{COLOR_BLUE}Starting to fetch tech and open source communities from Reddit.{COLOR_RESET}") + communities_fetched = 0 + + for topic in self.base_topics: + try: + subreddits = self.search_subreddits(topic) + for subreddit in subreddits: + if communities_fetched >= self.MAX_COMMUNITIES: + logger.info( + f"{COLOR_BLUE}Reached maximum communities limit ({self.MAX_COMMUNITIES}). Stopping.{COLOR_RESET}" + ) + return + + data = subreddit["data"] + + if data["display_name"] in self.processed_subreddits or not self.is_tech_related(data): + continue + + self.processed_subreddits.add(data["display_name"]) + created = self.save_subreddit(data) + + if created: + communities_fetched += 1 + logger.info(f"{COLOR_GREEN}Added r/{data['display_name']}{COLOR_RESET}") + + # For future use if we register the app on reddit api + # related_content = self.get_related_subreddits(data['display_name']) + # if related_content: + # for keyword in self.tech_keywords: + # if keyword in related_content: + # new_subreddits = self.search_subreddits(keyword) + # for new_sub in new_subreddits: + # if (new_sub['data']['display_name'] not in + # self.processed_subreddits): + # self.save_subreddit(new_sub['data']) + + sleep(1) + + except Exception as e: + logger.error(f"{COLOR_RED}Failed processing topic '{topic}': {str(e)}{COLOR_RESET}") + + logger.info(f"{COLOR_BLUE}Finished fetching communities. Total fetched: {communities_fetched}{COLOR_RESET}") diff --git a/website/migrations/0188_githubreview_opensourcerepo.py b/website/migrations/0188_githubreview_opensourcerepo.py deleted file mode 100644 index ce45db79b..000000000 --- a/website/migrations/0188_githubreview_opensourcerepo.py +++ /dev/null @@ -1,77 +0,0 @@ -# Generated by Django 5.1.4 on 2025-02-02 23:06 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("website", "0187_baconearning"), - ] - - operations = [ - migrations.CreateModel( - name="GitHubReview", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("review_id", models.IntegerField(unique=True)), - ("body", models.TextField(blank=True, null=True)), - ("state", models.CharField(max_length=50)), - ("submitted_at", models.DateTimeField()), - ("url", models.URLField()), - ( - "pull_request", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="reviews", - to="website.githubissue", - ), - ), - ( - "reviewer", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="reviews_made", - to="website.userprofile", - ), - ), - ], - ), - migrations.CreateModel( - name="OpenSourceRepo", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=255)), - ("owner", models.CharField(max_length=255)), - ("url", models.URLField()), - ("description", models.TextField(blank=True)), - ("primary_language", models.CharField(max_length=50)), - ("last_updated", models.DateTimeField()), - ("stars", models.PositiveIntegerField(default=0)), - ("forks", models.PositiveIntegerField(default=0)), - ("open_issues", models.PositiveIntegerField(default=0)), - ("last_pushed", models.DateTimeField()), - ("license", models.CharField(blank=True, max_length=100)), - ( - "tags", - models.ManyToManyField(related_name="repositories", to="website.tag"), - ), - ], - ), - ] diff --git a/website/models.py b/website/models.py index eb5708a57..f391718c9 100644 --- a/website/models.py +++ b/website/models.py @@ -1254,7 +1254,7 @@ def verify_file_upload(sender, instance, **kwargs): class Repo(models.Model): - project = models.ForeignKey(Project, related_name="repos", on_delete=models.CASCADE) + project = models.ForeignKey(Project, related_name="repos", on_delete=models.CASCADE, null=True, blank=True) name = models.CharField(max_length=255) slug = models.SlugField(unique=True, blank=True) description = models.TextField(null=True, blank=True) # Made nullable for optional descriptions @@ -1286,7 +1286,8 @@ class Repo(models.Model): release_datetime = models.DateTimeField(null=True, blank=True) logo_url = models.URLField(null=True, blank=True) contributor_count = models.IntegerField(default=0) - contributor = models.ManyToManyField(Contributor, related_name="repos", blank=True) + contributor = models.ManyToManyField(Contributor, related_name="repos", blank=True, null=True) + is_owasp_repo = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -1465,19 +1466,29 @@ def __str__(self): return f"Review #{self.review_id} by {self.reviewer.user.username} on PR #{self.pull_request.issue_id}" -class OpenSourceRepo(models.Model): - name = models.CharField(max_length=255) - owner = models.CharField(max_length=255) - url = models.URLField() - description = models.TextField(blank=True) - primary_language = models.CharField(max_length=50) - last_updated = models.DateTimeField() - tags = models.ManyToManyField("Tag", related_name="repositories") - stars = models.PositiveIntegerField(default=0) - forks = models.PositiveIntegerField(default=0) - open_issues = models.PositiveIntegerField(default=0) - last_pushed = models.DateTimeField() - license = models.CharField(max_length=100, blank=True) +from django.db import models + + +class OsshCommunity(models.Model): + CATEGORY_CHOICES = [ + ("forum", "Forum"), + ("community", "Community"), + ("mentorship", "Mentorship Program"), + ] + + name = models.CharField(max_length=255, unique=True) + description = models.TextField(blank=True, null=True) + website = models.URLField(unique=True, help_text="Direct link to the community") + source = models.CharField(max_length=100, help_text="Source API (GitHub, Dev.to, etc.)") + category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default="community") + external_id = models.CharField( + max_length=255, blank=True, null=True, unique=True, help_text="ID from external source" + ) + tags = models.ManyToManyField(Tag, related_name="communities", blank=True) + metadata = models.JSONField(default=dict, help_text="Additional API-specific metadata") + contributors_count = models.IntegerField(default=0, help_text="Approximate number of contributors") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return self.name + return f"{self.name} - {self.category}" From fd758802760e5d833c677ebf9559017bf24163fb Mon Sep 17 00:00:00 2001 From: SahilDhillon21 Date: Thu, 6 Feb 2025 18:57:00 +0530 Subject: [PATCH 4/4] Create Discussion Channel Model and command to fetch discord servers --- blt/settings.py | 1 + .../commands/fetch_discord_servers.py | 148 ++++++++++++++++++ ...sp_repo_alter_repo_contributor_and_more.py | 49 ++++++ website/models.py | 16 ++ 4 files changed, 214 insertions(+) create mode 100644 website/management/commands/fetch_discord_servers.py create mode 100644 website/migrations/0189_repo_is_owasp_repo_alter_repo_contributor_and_more.py diff --git a/blt/settings.py b/blt/settings.py index 885a79fa4..9769ece77 100644 --- a/blt/settings.py +++ b/blt/settings.py @@ -19,6 +19,7 @@ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "blank") +DISCORD_BOT_TOKEN = os.environ.get("DISCORD_BOT_TOKEN", "blank") PROJECT_NAME = "BLT" diff --git a/website/management/commands/fetch_discord_servers.py b/website/management/commands/fetch_discord_servers.py new file mode 100644 index 000000000..50a522a3b --- /dev/null +++ b/website/management/commands/fetch_discord_servers.py @@ -0,0 +1,148 @@ +import requests +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils.text import slugify + +from website.models import OsshDiscussionChannel, Tag + + +class Command(BaseCommand): + help = "Fetches public programming-related Discord servers" + + def handle(self, *args, **options): + token = settings.DISCORD_BOT_TOKEN + limit = 20 + + # Discord API endpoint for server discovery + url = "https://discord.com/api/v10/discovery/search" + headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"} + + # Search parameters for programming-related servers + search_terms = [ + "programming", + "coding", + "developers", + "software engineering", + "open source", + "opensource", + "open-source projects", + "FOSS", + "developer community", + "software development", + "coding help", + "hackathons", + "tech discussions", + "CS students", + "coding challenges", + "devops", + "AI developers", + "machine learning", + "data science", + "web development", + "backend development", + "frontend development", + "full stack developers", + "game development", + "cybersecurity", + "blockchain developers", + "cloud computing", + "Linux users", + "GitHub discussions", + "collaborative coding", + "tech startups", + "coding mentorship", + "bug bounty", + "ethical hacking", + "software architecture", + "API development", + "low-code/no-code", + "automation", + "scripting", + "Python developers", + "JavaScript developers", + "React developers", + "Django developers", + "Node.js developers", + "Rust programming", + "Go programming", + "Java developers", + "C++ programming", + "Android development", + "iOS development", + "open-source contributions", + "freeCodeCamp", + "100DaysOfCode", + "code reviews", + "pair programming", + "developer networking", + "open-source events", + "open-source maintainers", + "open-source contributors", + "community-driven development", + "open-source foundations", + ] + + all_servers = set() + + try: + for term in search_terms: + params = {"query": term, "limit": limit} + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + servers = response.json().get("hits", []) + + for server in servers: + server_id = server.get("id") + + if server_id in all_servers: + continue + + all_servers.add(server_id) + + server_info = { + "name": server.get("name", "Unknown"), + "description": server.get("description", ""), + "member_count": server.get("approximate_member_count", 0), + "id": server_id, + "logo_url": f"https://cdn.discordapp.com/icons/{server_id}/{server.get('icon')}.png" + if server.get("icon") + else "", + "tags": server.get("keywords", []), + } + + channel, created = OsshDiscussionChannel.objects.update_or_create( + external_id=server_info["id"], + defaults={ + "name": server_info["name"], + "description": server_info["description"], + "source": "Discord", + "member_count": server_info["member_count"], + "logo_url": server_info["logo_url"], + }, + ) + + for tag_name in server_info["tags"]: + slug = slugify(tag_name) + + tag = Tag.objects.filter(slug=slug).first() + + if not tag: + tag = Tag.objects.create(name=tag_name, slug=slug) + + channel.tags.add(tag) + + status = "Created" if created else "Updated" + self.stdout.write(self.style.SUCCESS(f"\n{'='*50}")) + self.stdout.write(self.style.SUCCESS(f"{status} Server: {channel.name}")) + self.stdout.write(self.style.NOTICE(f"Description: {channel.description[:100]}...")) + self.stdout.write(self.style.WARNING(f"Members: {channel.member_count:,}")) + self.stdout.write(self.style.SQL_FIELD(f"Server ID: {channel.external_id}")) + self.stdout.write(self.style.SQL_FIELD(f"Logo URL: {channel.logo_url}")) + self.stdout.write(self.style.NOTICE(f"Tags: {', '.join(tag.name for tag in channel.tags.all())}")) + + self.stdout.write(self.style.SUCCESS(f"\n{'='*50}")) + self.stdout.write(self.style.SUCCESS(f"\nTotal unique servers processed: {len(all_servers)}")) + + except requests.exceptions.RequestException as e: + self.stderr.write(self.style.ERROR(f"Error fetching servers: {str(e)}")) diff --git a/website/migrations/0189_repo_is_owasp_repo_alter_repo_contributor_and_more.py b/website/migrations/0189_repo_is_owasp_repo_alter_repo_contributor_and_more.py new file mode 100644 index 000000000..4cf55f206 --- /dev/null +++ b/website/migrations/0189_repo_is_owasp_repo_alter_repo_contributor_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.4 on 2025-02-06 12:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0188_kudos"), + ] + + operations = [ + migrations.CreateModel( + name="OsshDiscussionChannel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ( + "source", + models.CharField(help_text="Source API (Discord, Slack etc)", max_length=100), + ), + ( + "external_id", + models.CharField( + help_text="Server ID from the platform", + max_length=100, + unique=True, + ), + ), + ("member_count", models.PositiveIntegerField(default=0)), + ("invite_url", models.URLField(blank=True)), + ("logo_url", models.URLField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "tags", + models.ManyToManyField(blank=True, related_name="channels", to="website.tag"), + ), + ], + ), + ] diff --git a/website/models.py b/website/models.py index d4e23200e..4e836289a 100644 --- a/website/models.py +++ b/website/models.py @@ -1505,3 +1505,19 @@ class OsshCommunity(models.Model): contributors_count = models.IntegerField(default=0, help_text="Approximate number of contributors") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + + +class OsshDiscussionChannel(models.Model): + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + source = models.CharField(max_length=100, help_text="Source API (Discord, Slack etc)") + external_id = models.CharField(max_length=100, unique=True, help_text="Server ID from the platform") + member_count = models.PositiveIntegerField(default=0) + invite_url = models.URLField(blank=True) + logo_url = models.URLField(blank=True) + tags = models.ManyToManyField(Tag, blank=True, related_name="channels") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name