Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions backend/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
include backend/apps/ai/Makefile
include backend/apps/github/Makefile
include backend/apps/mentorship/Makefile
include backend/apps/owasp/Makefile
include backend/apps/slack/Makefile

Expand Down
1 change: 1 addition & 0 deletions backend/apps/github/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Github app admin."""

from .issue import IssueAdmin
from .issue_comment import IssueCommentAdmin
from .label import LabelAdmin
from .milestone import MilestoneAdmin
from .organization import OrganizationAdmin
Expand Down
1 change: 1 addition & 0 deletions backend/apps/github/admin/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class IssueAdmin(admin.ModelAdmin):
)
list_filter = (
"state",
"created_at",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why adding this?

"is_locked",
)
search_fields = ("title",)
Expand Down
22 changes: 22 additions & 0 deletions backend/apps/github/admin/issue_comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""GitHub app Issue model admin."""

from django.contrib import admin

from apps.github.models import IssueComment


class IssueCommentAdmin(admin.ModelAdmin):
"""Admin for IssueComment model."""

list_display = (
"body",
"issue",
"author",
"created_at",
"updated_at",
)
list_filter = ("created_at", "updated_at")
search_fields = ("body", "issue__title")


admin.site.register(IssueComment, IssueCommentAdmin)
94 changes: 94 additions & 0 deletions backend/apps/github/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from github.GithubException import UnknownObjectException

from apps.github.models.issue import Issue
from apps.github.models.issue_comment import IssueComment
from apps.github.models.label import Label
from apps.github.models.milestone import Milestone
from apps.github.models.organization import Organization
Expand Down Expand Up @@ -227,3 +228,96 @@ def sync_repository(
)

return organization, repository


def sync_issue_comments(gh_app, issue: Issue):
"""Sync new comments for a mentorship program specific issue on-demand.

Args:
gh_app (Github): An authenticated PyGithub instance.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are you sure it's an app here?

issue (Issue): The local database Issue object to sync comments for.

"""
logger.info("Starting comment sync for issue #%s", issue.number)

try:
repo = issue.repository
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use full names repository

if not repo:
logger.warning("Issue #%s has no repository, skipping", issue.number)
return

if not repo.owner:
Copy link
Collaborator

Choose a reason for hiding this comment

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

What are these repositories that have no owner?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

I meant we don't have repositories w/o owners in our DB.

logger.warning("Repository for issue #%s has no owner, skipping", issue.number)
return

repo_full_name = f"{repo.owner.login}/{repo.name}"
logger.info("Fetching repository: %s", repo_full_name)

gh_repo = gh_app.get_repo(repo_full_name)
gh_issue = gh_repo.get_issue(number=issue.number)

last_comment = issue.comments.order_by("-created_at").first()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add this as a property for Issue -- we have latest_release, latest_pull_request as examples.

since = None

if last_comment:
since = last_comment.created_at
logger.info("Found last comment at: %s, fetching newer comments", since)
else:
logger.info("No existing comments found, fetching all comments")

existing_github_ids = set(issue.comments.values_list("github_id", flat=True))

comments_synced = 0

gh_comments = gh_issue.get_comments(since=since) if since else gh_issue.get_comments()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does it work with just gh_comments = gh_issue.get_comments(since=since), e.g when it's None?

Copy link
Collaborator Author

@Rajgupta36 Rajgupta36 Sep 11, 2025

Choose a reason for hiding this comment

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

sorry i have checked the github type for this. when we pass null it throws error.
description
. This is the type(Argument of type "None" cannot be assigned to parameter "since" of type "Opt[datetime]" in function "get_comments"
Type "None" is not assignable to type "Opt[datetim

Copy link
Collaborator

Choose a reason for hiding this comment

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

Okay, thanks for confirming


for gh_comment in gh_comments:
if gh_comment.id in existing_github_ids:
logger.info("Skipping existing comment %s", gh_comment.id)
continue
Copy link
Collaborator

Choose a reason for hiding this comment

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

Doesn't since make sure that there will be only updated comments?

Copy link
Collaborator Author

@Rajgupta36 Rajgupta36 Sep 5, 2025

Choose a reason for hiding this comment

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

since parameter tells gitHub to return comments that were created or updated after the given timestamp.

Copy link
Collaborator

Choose a reason for hiding this comment

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

That's what I'm talking about -- if only updated comments returned you don't need the check for existing ones, you need to make sure the content is updated. Maybe someone changed their mind and is no longer interested in the task


if since and gh_comment.created_at <= since:
logger.info("Skipping comment %s - not newer than our last comment", gh_comment.id)
continue

author_obj = User.update_data(gh_comment.user)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder how many names with _obj suffix you can find in the code base. This is redundant, they are all objects obviously. You don't even have author taken by some other value. Why this kind of naming 🤷‍♂️ ?


if author_obj:
try:
comment_obj = IssueComment.update_data(gh_comment, issue, author_obj)
if comment_obj:
comments_synced += 1
logger.info(
"Synced new comment %s for issue #%s", gh_comment.id, issue.number
)
except Exception:
logger.exception(
"Failed to create comment %s for issue #%s",
gh_comment.id,
issue.number,
)
else:
logger.warning("Could not sync author for comment %s", gh_comment.id)

if comments_synced > 0:
logger.info(
"Synced %d new comments for issue #%s in %s",
comments_synced,
issue.number,
issue.repository.name,
)
else:
logger.info("No new comments found for issue #%s", issue.number)

except UnknownObjectException as e:
logger.warning(
"Could not access issue #%s in %s. Error: %s",
issue.number,
repo_full_name,
str(e),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you need str() here?

)
except Exception:
logger.exception(
"An unexpected error occurred during comment sync for issue #%s",
issue.number,
)
45 changes: 45 additions & 0 deletions backend/apps/github/migrations/0034_issuecomment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Generated by Django 5.2.4 on 2025-08-14 19:16

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


class Migration(migrations.Migration):
dependencies = [
("github", "0033_alter_release_published_at"),
]

operations = [
migrations.CreateModel(
name="IssueComment",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("github_id", models.BigIntegerField(unique=True)),
("body", models.TextField()),
("created_at", models.DateTimeField()),
("updated_at", models.DateTimeField()),
(
"author",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="issue_comments",
to="github.user",
),
),
(
"issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="github.issue",
),
),
],
),
]
1 change: 1 addition & 0 deletions backend/apps/github/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Github app."""

from .issue_comment import IssueComment
from .milestone import Milestone
from .pull_request import PullRequest
from .user import User
35 changes: 35 additions & 0 deletions backend/apps/github/models/issue_comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""GitHub app issue comment model."""

from django.db import models


class IssueComment(models.Model):
"""Represents a comment on a GitHub issue."""

github_id = models.BigIntegerField(unique=True)
Copy link
Collaborator

Choose a reason for hiding this comment

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

What are the values here, do you have examples?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

image

issue = models.ForeignKey("github.Issue", on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(
"github.User", on_delete=models.SET_NULL, null=True, related_name="issue_comments"
)
body = models.TextField()
created_at = models.DateTimeField()
updated_at = models.DateTimeField()

@classmethod
def update_data(cls, gh_comment, issue_obj, author_obj):
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not how update_data works. Please check other model examples. This needs bulk save support and should never create an object unless save=True is passed.

"""Create or update an IssueComment instance from a GitHub API object."""
comment, _ = cls.objects.update_or_create(
github_id=gh_comment.id,
defaults={
"issue": issue_obj,
"author": author_obj,
"body": gh_comment.body,
"created_at": gh_comment.created_at,
"updated_at": gh_comment.updated_at,
},
)
return comment

def __str__(self):
"""Return a string representation of the issue comment."""
return f"{self.issue} - {self.author} - {self.body}"
3 changes: 3 additions & 0 deletions backend/apps/mentorship/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
sync_mentorship_issue_comments:
@echo "Syncing Github Comments related to issues"
@CMD="python manage.py sync_mentorship_issue_comments" $(MAKE) exec-backend-command
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please follow the naming patterns we use for management commands.

1 change: 1 addition & 0 deletions backend/apps/mentorship/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Mentorship app admin."""

from .interested_contributors import ParticipantInterestAdmin
from .mentee import MenteeAdmin
from .mentee_program import MenteeProgramAdmin
from .mentor import MentorAdmin
Expand Down
22 changes: 22 additions & 0 deletions backend/apps/mentorship/admin/interested_contributors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Mentorship app interested contributors admin."""

from django.contrib import admin

from apps.mentorship.models import ParticipantInterest


class ParticipantInterestAdmin(admin.ModelAdmin):
"""ParticipantInterest admin."""

list_display = ("program", "issue", "users_count")
search_fields = ("program__name", "users__login", "issue__title")
list_filter = ("program", "issue")

def users_count(self, obj):
"""Return the count of users interested in the issue."""
return obj.users.count()

users_count.short_description = "Users interested"


admin.site.register(ParticipantInterest, ParticipantInterestAdmin)
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Syncs comments for issues relevant to active mentorship programs."""

import logging
import re

from django.core.management.base import BaseCommand
from github.GithubException import GithubException

from apps.github.auth import get_github_client
from apps.github.common import sync_issue_comments
from apps.github.models.issue import Issue
from apps.mentorship.models import ParticipantInterest, Program

logger = logging.getLogger(__name__)

INTEREST_PATTERNS = [
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 not overcomplicate it and use /interested as the only pattern/command for now.

re.compile(p, re.IGNORECASE)
for p in [
r"assign.*me",
r"i(?:'d| would)? like to work on",
r"can i work on",
r"i(?:'ll| will)? take",
r"i want to work on",
r"i am interested",
r"can i be assigned",
r"please assign.*me",
r"i can (?:help|work|fix|handle)",
r"let me (?:work|take|handle)",
r"i(?:'ll| will).*(?:fix|handle|work)",
r"assign.*to.*me",
r"i volunteer",
r"count me in",
r"i(?:'m| am) up for",
r"i could work",
r"happy.*work",
r"i(?:'d| would) love to work",
]
]


class Command(BaseCommand):
"""Syncs comments for issues relevant to active mentorship programs."""

def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS("Starting mentorship issue processing job..."))

gh = get_github_client()
active_programs = Program.objects.filter(status=Program.ProgramStatus.PUBLISHED)
if not active_programs.exists():
self.stdout.write(self.style.WARNING("No active mentorship programs found. Exiting."))
return

for program in active_programs:
self.stdout.write(f"\nProcessing program: {program.name}...")

try:
program_repos = (
program.modules.filter(project__repositories__isnull=False)
.values_list("project__repositories", flat=True)
.distinct()
)
self.stdout.write(
f"Program '{program.name}' have {program_repos.count()} repositories."
)

if not program_repos.exists():
self.stdout.write(
self.style.WARNING(f"Skipping. {program.name} has no repositories.")
)
continue

relevant_issues = Issue.objects.filter(
repository_id__in=program_repos, state="open"
).distinct()

self.stdout.write(f"Found {relevant_issues.count()} open issues across ")

except GithubException as e:
self.stdout.write(
self.style.WARNING(
f"Skipping. Error querying GitHub data for program '{program.name}'. {e}"
)
)
continue

for issue in relevant_issues:
self.stdout.write(
f"Syncing new comments for issue #{issue.number} '{issue.title[:20]}...'"
)
sync_issue_comments(gh, issue)

self._find_and_register_interest(issue, program)

self.stdout.write(self.style.SUCCESS("\n processed successfully!"))

def _find_and_register_interest(self, issue: Issue, program: Program):
"""Find and register users who expressed interest in given issue as part of program."""
interest_obj, _ = ParticipantInterest.objects.get_or_create(program=program, issue=issue)

existing_user_ids = set(interest_obj.users.values_list("id", flat=True))
to_add = []
new_user_logins = []

for comment in issue.comments.select_related("author").all():
if not comment.author:
continue

if comment.author_id in existing_user_ids:
continue

body = comment.body or ""
if any(p.search(body) for p in INTEREST_PATTERNS):
to_add.append(comment.author)
new_user_logins.append(comment.author.login)
existing_user_ids.add(comment.author_id)

if to_add:
interest_obj.users.add(*to_add)
self.stdout.write(
self.style.SUCCESS(
f"+ Added {len(to_add)} new user(s) "
f"to issue #{issue.number}: {', '.join(new_user_logins)}"
)
)
Loading