Skip to content
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
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/nest/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,5 +1,6 @@
"""Github app admin."""

from .comment import CommentAdmin
from .issue import IssueAdmin
from .label import LabelAdmin
from .milestone import MilestoneAdmin
Expand Down
21 changes: 21 additions & 0 deletions backend/apps/github/admin/comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""GitHub app Comment model admin."""

from django.contrib import admin

from apps.github.models import Comment


class CommentAdmin(admin.ModelAdmin):
"""Admin for Comment model."""

list_display = (
"body",
"author",
"nest_created_at",
"nest_updated_at",
)
list_filter = ("nest_created_at", "nest_updated_at")
search_fields = ("body", "author__login")


admin.site.register(Comment, CommentAdmin)
70 changes: 70 additions & 0 deletions backend/apps/github/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@

import logging
from datetime import timedelta as td
from typing import TYPE_CHECKING

from django.utils import timezone
from github.GithubException import UnknownObjectException

if TYPE_CHECKING:
from github import Github

from apps.github.models.comment import Comment
from apps.github.models.issue import Issue
from apps.github.models.label import Label
from apps.github.models.milestone import Milestone
Expand Down Expand Up @@ -227,3 +232,68 @@ def sync_repository(
)

return organization, repository


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

Args:
gh_client (Github): GitHub client.
issue (Issue): The local database Issue object to sync comments for.

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

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

logger.info("Fetching repository: %s", repository.path)

gh_repository = gh_client.get_repo(repository.path)
gh_issue = gh_repository.get_issue(number=issue.number)

since = (
(issue.latest_comment.updated_at or issue.latest_comment.created_at)
if issue.latest_comment
else getattr(issue, "updated_at", None)
)

comments = []

gh_comments = gh_issue.get_comments(since=since) if since else gh_issue.get_comments()

for gh_comment in gh_comments:
author = User.update_data(gh_comment.user)
if not author:
logger.warning("Could not sync author for comment %s", gh_comment.id)
continue

comment = Comment.update_data(
gh_comment,
author=author,
content_object=issue,
save=False,
)
comments.append(comment)

if comments:
Comment.bulk_save(comments)
Copy link
Collaborator

Choose a reason for hiding this comment

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

You run the bulk save for each comment/iteration. It doesn't make sense 🤷‍♂️

logger.info(
"%d comments synced for issue #%s",
len(comments),
issue.number,
)

except UnknownObjectException as e:
logger.warning(
"Could not access issue #%s. Error: %s",
issue.number,
e,
)
except Exception:
logger.exception(
"An unexpected error occurred during comment sync for issue #%s",
issue.number,
)
61 changes: 61 additions & 0 deletions backend/apps/github/migrations/0036_comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 5.2.5 on 2025-09-24 13:14

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


class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("github", "0035_alter_user_bio_alter_user_is_owasp_staff"),
]

operations = [
migrations.CreateModel(
name="Comment",
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)),
("github_id", models.BigIntegerField(unique=True, verbose_name="Github ID")),
(
"created_at",
models.DateTimeField(blank=True, null=True, verbose_name="Created at"),
),
(
"updated_at",
models.DateTimeField(
blank=True, db_index=True, null=True, verbose_name="Updated at"
),
),
("body", models.TextField(verbose_name="Body")),
("object_id", models.PositiveIntegerField()),
(
"author",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="comments",
to="github.user",
),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "github_comments",
"ordering": ("-nest_created_at",),
},
),
]
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 .comment import Comment
from .milestone import Milestone
from .pull_request import PullRequest
from .user import User
85 changes: 85 additions & 0 deletions backend/apps/github/models/comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""GitHub app comment model."""

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models

from apps.common.models import BulkSaveModel, TimestampedModel
from apps.common.utils import truncate


class Comment(BulkSaveModel, TimestampedModel):
"""Represents a comment on a GitHub Issue."""

class Meta:
db_table = "github_comments"
verbose_name = "Comment"
verbose_name_plural = "Comments"
ordering = ("-nest_created_at",)

github_id = models.BigIntegerField(unique=True, verbose_name="Github ID")
created_at = models.DateTimeField(verbose_name="Created at", null=True, blank=True)
updated_at = models.DateTimeField(
verbose_name="Updated at", null=True, blank=True, db_index=True
)
author = models.ForeignKey(
"github.User", on_delete=models.SET_NULL, null=True, related_name="comments"
)
body = models.TextField(verbose_name="Body")

content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")

def __str__(self):
"""Return a string representation of the comment."""
return f"{self.author} - {truncate(self.body, 50)}"

def from_github(self, gh_comment, author=None):
"""Populate fields from a GitHub API comment object."""
field_mapping = {
"body": "body",
"created_at": "created_at",
"updated_at": "updated_at",
}

for model_field, gh_field in field_mapping.items():
value = getattr(gh_comment, gh_field, None)
if value is not None:
setattr(self, model_field, value)

self.author = author

@staticmethod
def bulk_save(comments, fields=None): # type: ignore[override]
"""Bulk save comments."""
BulkSaveModel.bulk_save(Comment, comments, fields=fields)

@staticmethod
def update_data(gh_comment, *, author=None, content_object=None, save: bool = True):
"""Update or create a Comment instance from a GitHub comment object.

Args:
gh_comment (github.IssueComment.IssueComment): GitHub comment object.
author (User, optional): Comment author. Defaults to None.
content_object (GenericForeignKey, optional): Content object. Defaults to None.
save (bool, optional): Whether to save the instance immediately. Defaults to True.

Returns:
Comment: The updated or newly created Comment instance.

"""
try:
comment = Comment.objects.get(github_id=gh_comment.id)
except Comment.DoesNotExist:
comment = Comment(github_id=gh_comment.id)

comment.from_github(gh_comment, author=author)

if content_object is not None:
comment.content_object = content_object

if save:
comment.save()

return comment
14 changes: 14 additions & 0 deletions backend/apps/github/models/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from functools import lru_cache

from django.contrib.contenttypes.fields import GenericRelation
from django.db import models

from apps.common.index import IndexBase
Expand Down Expand Up @@ -54,6 +55,9 @@ class Meta:
null=True,
related_name="created_issues",
)

comments = GenericRelation("github.Comment", related_query_name="issue")

milestone = models.ForeignKey(
"github.Milestone",
on_delete=models.CASCADE,
Expand Down Expand Up @@ -83,6 +87,16 @@ class Meta:
blank=True,
)

@property
def latest_comment(self):
"""Get the latest comment for this issue.

Returns:
Comment | None: The most recently created comment, or None if no comments exist.

"""
return self.comments.order_by("-nest_created_at").first()

def from_github(self, gh_issue, *, author=None, milestone=None, repository=None):
"""Update the instance based on GitHub issue data.

Expand Down
3 changes: 3 additions & 0 deletions backend/apps/mentorship/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mentorship-update-comments:
@echo "Syncing Github Comments related to issues"
@CMD="python manage.py mentorship_update_comments" $(MAKE) exec-backend-command
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 .issue_user_interest import IssueUserInterest
from .mentee import MenteeAdmin
from .mentee_program import MenteeProgramAdmin
from .mentor import MentorAdmin
Expand Down
16 changes: 16 additions & 0 deletions backend/apps/mentorship/admin/issue_user_interest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Mentorship app IssueUserInterest admin."""

from django.contrib import admin

from apps.mentorship.models import IssueUserInterest


class IssueUserInterestAdmin(admin.ModelAdmin):
"""IssueUserInterest admin."""

list_display = ("module", "issue")
search_fields = ("module__name", "user__login", "issue__title")
list_filter = ("module",)


admin.site.register(IssueUserInterest, IssueUserInterestAdmin)
Empty file.
Empty file.
Loading