Skip to content
Draft
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/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class IssueAdmin(admin.ModelAdmin):
"repository",
"created_at",
"title",
"level",
"custom_field_github_url",
)
list_filter = (
Expand Down
26 changes: 26 additions & 0 deletions backend/apps/github/migrations/0035_issue_level.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 5.2.4 on 2025-08-17 12:10

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


class Migration(migrations.Migration):
dependencies = [
("github", "0034_merge_20250804_1817"),
("mentorship", "0006_remove_task_level_alter_task_assigned_at"),
]

operations = [
migrations.AddField(
model_name="issue",
name="level",
field=models.ForeignKey(
blank=True,
help_text="The difficulty level of this issue.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tasks",
to="mentorship.tasklevel",
),
),
]
8 changes: 8 additions & 0 deletions backend/apps/github/models/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ class Meta:
null=True,
related_name="created_issues",
)
level = models.ForeignKey(
"mentorship.TaskLevel",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="tasks",
help_text="The difficulty level of this issue.",
)
milestone = models.ForeignKey(
"github.Milestone",
on_delete=models.CASCADE,
Expand Down
5 changes: 5 additions & 0 deletions backend/apps/mentorship/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
sync/module-issues:
@CMD="python manage.py sync_module_issues -v 2" $(MAKE) exec-backend-command

sync/issue-levels:
@CMD="python manage.py sync_issue_levels" $(MAKE) exec-backend-command
1 change: 1 addition & 0 deletions backend/apps/mentorship/admin/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ModuleAdmin(admin.ModelAdmin):
"program",
"project",
)
autocomplete_fields = ("issues",)

search_fields = (
"name",
Expand Down
8 changes: 2 additions & 6 deletions backend/apps/mentorship/admin/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,9 @@ class TaskAdmin(admin.ModelAdmin):
"deadline_at",
)

search_fields = (
"issue__title",
"assignee__github_user__login",
"module__name",
)

list_filter = ("status", "module")

ordering = ["-assigned_at"]


admin.site.register(Task, TaskAdmin)
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""A command to sync issue level with Tasklevel."""

from django.core.management.base import BaseCommand
from django.db.models import Prefetch

from apps.github.models.issue import Issue
from apps.github.models.label import Label
from apps.mentorship.models.task_level import TaskLevel
from apps.mentorship.utils import normalize_name


class Command(BaseCommand):
"""Syncs the `level` field on Issues based on matching labels, respecting Module constraints.

If any label matches a TaskLevel in the Issue's Module, that TaskLevel is assigned.
"""

help = "Assigns a TaskLevel to each Issue by matching labels within the same Module."

def _build_module_level_maps(self, all_levels):
"""Build a mapping from module ID to a dictionary of data.

The dictionary contains a 'label_to_level_map' for normalized label/level
names to TaskLevel objects.
"""
module_data_map = {}
for level in all_levels:
module_id = level.module_id
level_map_container = module_data_map.setdefault(module_id, {"label_to_level_map": {}})
level_map = level_map_container["label_to_level_map"]

normalized_level_name = normalize_name(level.name)
level_map[normalized_level_name] = level

for label_name in level.labels:
normalized_label = normalize_name(label_name)
level_map[normalized_label] = level
return module_data_map

def _find_best_match_level(
self,
issue_labels_normalized,
issue_mentorship_modules,
module_data_map,
):
"""Find the best matching TaskLevel for an issue based on its labels and modules."""
for module in issue_mentorship_modules:
if module.id in module_data_map:
module_level_map = module_data_map[module.id]["label_to_level_map"]
for label_name in issue_labels_normalized:
if label_name in module_level_map:
return module_level_map[label_name]
return None

def handle(self, *args, **options):
self.stdout.write("Starting...")

# 1. Build a per-module map (normalized label → TaskLevel)
all_levels = TaskLevel.objects.select_related("module").order_by("name")

if not all_levels.exists():
self.stdout.write(
self.style.WARNING("No TaskLevel objects found in the database. Exiting.")
)
return

module_data_map = self._build_module_level_maps(all_levels)
self.stdout.write(f"Built label maps for {len(module_data_map)} modules.")

# 2.match issue labels to TaskLevels
issues_to_update = []
issues_query = Issue.objects.prefetch_related(
Prefetch("labels", queryset=Label.objects.only("name")),
"mentorship_modules",
).select_related("level")

for issue in issues_query:
issue_labels_normalized = {normalize_name(label.name) for label in issue.labels.all()}

best_match_level = self._find_best_match_level(
issue_labels_normalized,
list(issue.mentorship_modules.all()),
module_data_map,
)

if issue.level != best_match_level:
issue.level = best_match_level
issues_to_update.append(issue)

if issues_to_update:
updated_count = len(issues_to_update)
Issue.objects.bulk_update(issues_to_update, ["level"])
self.stdout.write(
self.style.SUCCESS(f"Successfully updated the level for {updated_count} issues.")
)
else:
self.stdout.write(self.style.SUCCESS("All issue levels are already up-to-date."))
Loading