Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a36883c
Add bi-directional sync feature
Zahed-Riyaz Aug 11, 2025
3c2ca3d
Clean up files
Zahed-Riyaz Aug 16, 2025
45e6370
Rename migration file
Zahed-Riyaz Aug 20, 2025
61b0c24
Add github_token to serializer fields and requests
Zahed-Riyaz Aug 23, 2025
c9db18a
Update sync logic
Zahed-Riyaz Aug 24, 2025
6f59a0d
Implement middleware logic
Zahed-Riyaz Aug 25, 2025
79771c4
Fix field order
Zahed-Riyaz Aug 25, 2025
3d97a03
Omit debug statements
Zahed-Riyaz Aug 25, 2025
9c2b699
Fix github_branch migration file
Zahed-Riyaz Aug 26, 2025
650d823
Pass linting checks
Zahed-Riyaz Aug 26, 2025
cab85e1
Merge branch 'master' into github-sync
Zahed-Riyaz Aug 26, 2025
d581b4b
Add tests
Zahed-Riyaz Aug 26, 2025
10ee702
Add tests for github_utils
Zahed-Riyaz Aug 26, 2025
2eaa465
Pass code quality checks
Zahed-Riyaz Aug 26, 2025
f13a78d
Modify tests
Zahed-Riyaz Aug 26, 2025
dff14ee
Omit non essential model fields
Zahed-Riyaz Aug 26, 2025
8ec3b79
Merge branch 'master' into github-sync
RishabhJain2018 Sep 1, 2025
6686c03
Merge branch 'master' into github-sync
Zahed-Riyaz Sep 1, 2025
d277d88
Pass code quality checks
Zahed-Riyaz Sep 1, 2025
33a7677
Merge branch 'master' into github-sync
Zahed-Riyaz Sep 1, 2025
4a05fa5
Pass code quality checks
Zahed-Riyaz Sep 1, 2025
9cd82fc
Add coverage for missing lines
Zahed-Riyaz Sep 2, 2025
aefbf7a
Merge branch 'master' into github-sync
Zahed-Riyaz Sep 2, 2025
9584291
Fix TestDeserializeObject error
Zahed-Riyaz Sep 2, 2025
65cf97f
Update .travis.yml
Zahed-Riyaz Sep 3, 2025
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
Prev Previous commit
Next Next commit
Update sync logic
  • Loading branch information
Zahed-Riyaz committed Aug 24, 2025
commit c9db18aaadd2aab03bda2278e1f8834d8d142acf
338 changes: 329 additions & 9 deletions apps/challenges/github_interface.py

Large diffs are not rendered by default.

28 changes: 26 additions & 2 deletions apps/challenges/github_sync_config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
# Fields from Challenge, ChallengePhase model to be considered for github_sync
# If you are not sure what all these fields mean, please refer our documentation here:
# https://evalai.readthedocs.io/en/latest/configuration.html

challenge_non_file_fields = [
"title",
"short_description",
"short_description",
"leaderboard_description",
"remote_evaluation",
"is_docker_based",
"is_static_dataset_code_upload",
"start_date",
"end_date",
"published",
"image",
"evaluation_script",
"tags",
]

challenge_file_fields = [
Expand All @@ -20,18 +25,37 @@
]

challenge_phase_non_file_fields = [
"id",
"name",
"leaderboard_public",
"is_public",
"challenge",
"is_active",
"max_concurrent_submissions_allowed",
"allowed_email_ids",
"disable_logs",
"is_submission_public",
"start_date",
"end_date",
"test_annotation_file",
"codename",
"max_submissions_per_day",
"max_submissions_per_month",
"max_submissions",
"is_restricted_to_select_one_submission",
"is_partial_submission_evaluation_enabled",
"allowed_submission_file_types",
"default_submission_meta_attributes",
"submission_meta_attributes",
]

challenge_phase_file_fields = ["description"]
challenge_phase_file_fields = [
"description",
]

# Additional sections that should be synced
challenge_additional_sections = [
"leaderboard",
"dataset_splits",
"challenge_phase_splits",
]
184 changes: 80 additions & 104 deletions apps/challenges/github_utils.py
Original file line number Diff line number Diff line change
@@ -1,119 +1,95 @@
import logging
import yaml

from base.utils import deserialize_object
from .github_sync_config import (
challenge_non_file_fields,
challenge_file_fields,
challenge_phase_non_file_fields,
challenge_phase_file_fields,
)
from django.utils import timezone
from .models import Challenge, ChallengePhase
from .github_interface import GithubInterface
from evalai.celery import app

logger = logging.getLogger(__name__)


@app.task
def github_challenge_sync(challenge):
challenge = deserialize_object(challenge)
github = GithubInterface(
GITHUB_REPOSITORY=getattr(challenge, "github_repository"),
GITHUB_AUTH_TOKEN=getattr(challenge, "github_token"),
)
if not github.is_repository():
return
# Global set to track challenges currently being synced
_sync_in_progress = set()

def github_challenge_sync(challenge_id):
"""
Simple sync from EvalAI to GitHub
This is the core function that keeps GitHub in sync with EvalAI
"""
# Prevent multiple simultaneous syncs for the same challenge
if challenge_id in _sync_in_progress:
logger.info(f"Challenge {challenge_id} sync already in progress, skipping")
return False

try:
# Challenge non-file field update
challenge_config_str = github.get_data_from_path(
"challenge_config.yaml"
# Mark this challenge as being synced
_sync_in_progress.add(challenge_id)

challenge = Challenge.objects.get(id=challenge_id)

if not challenge.github_repository or not challenge.github_token:
logger.warning(f"Challenge {challenge_id} missing GitHub configuration")
return False

# Initialize GitHub interface
github_interface = GithubInterface(
challenge.github_repository,
challenge.github_branch or 'challenge', # Default to 'challenge' branch
challenge.github_token
)
challenge_config_yaml = yaml.safe_load(challenge_config_str)
update_challenge_config = False
for field in challenge_non_file_fields:
# Ignoring commits when no update in field value
if challenge_config_yaml.get(
field
) is not None and challenge_config_yaml[field] == getattr(
challenge, field
):
continue
update_challenge_config = True
challenge_config_yaml[field] = getattr(challenge, field)
if update_challenge_config:
content_str = yaml.dump(challenge_config_yaml, sort_keys=False)
github.update_data_from_path("challenge_config.yaml", content_str)

# Challenge file fields update
for field in challenge_file_fields:
if challenge_config_yaml.get(field) is None:
continue
field_path = challenge_config_yaml[field]
field_str = github.get_data_from_path(field_path)
if field_str is None or field_str == getattr(challenge, field):
continue
github.update_data_from_path(field_path, getattr(challenge, field))

# Update challenge config in GitHub
success = github_interface.update_challenge_config(challenge)

if success:
logger.info(f"Successfully synced challenge {challenge_id} to GitHub")
return True
else:
logger.error(f"Failed to sync challenge {challenge_id} to GitHub")
return False

except Challenge.DoesNotExist:
logger.error(f"Challenge {challenge_id} not found")
return False
except Exception as e:
logger.error("Github Sync unsuccessful due to {}".format(e))
logger.error(f"Error syncing challenge {challenge_id} to GitHub: {str(e)}")
return False
finally:
# Always remove from in-progress set
_sync_in_progress.discard(challenge_id)


@app.task
def github_challenge_phase_sync(challenge_phase):
challenge_phase = deserialize_object(challenge_phase)
challenge = challenge_phase.challenge
github = GithubInterface(
GITHUB_REPOSITORY=getattr(challenge, "github_repository"),
GITHUB_AUTH_TOKEN=getattr(challenge, "github_token"),
)
if not github.is_repository():
return
def github_challenge_phase_sync(challenge_phase_id):
"""
Sync challenge phase from EvalAI to GitHub
"""
try:
# Challenge phase non-file field update
challenge_phase_unique = "codename"
challenge_config_str = github.get_data_from_path(
"challenge_config.yaml"
challenge_phase = ChallengePhase.objects.get(id=challenge_phase_id)
challenge = challenge_phase.challenge

if not challenge.github_repository or not challenge.github_token:
logger.warning(f"Challenge {challenge.id} missing GitHub configuration")
return False

# Initialize GitHub interface
github_interface = GithubInterface(
challenge.github_repository,
challenge.github_branch or 'challenge', # Default to 'challenge' branch
challenge.github_token
)
challenge_config_yaml = yaml.safe_load(challenge_config_str)
update_challenge_config = False

for phase in challenge_config_yaml["challenge_phases"]:
if phase.get(challenge_phase_unique) != getattr(
challenge_phase, challenge_phase_unique
):
continue
for field in challenge_phase_non_file_fields:
# Ignoring commits when no update in field value
if phase.get(field) is not None and phase[field] == getattr(
challenge_phase, field
):
continue
update_challenge_config = True
phase[field] = getattr(challenge_phase, field)
break
if update_challenge_config:
content_str = yaml.dump(challenge_config_yaml, sort_keys=False)
github.update_data_from_path("challenge_config.yaml", content_str)

# Challenge phase file fields update
for phase in challenge_config_yaml["challenge_phases"]:
if phase.get(challenge_phase_unique) != getattr(
challenge_phase, challenge_phase_unique
):
continue
for field in challenge_phase_file_fields:
if phase.get(field) is None:
continue
field_path = phase[field]
field_str = github.get_data_from_path(field_path)
if field_str is None or field_str == getattr(
challenge_phase, field
):
continue
github.update_data_from_path(
field_path, getattr(challenge_phase, field)
)
break

# Update challenge phase config in GitHub
success = github_interface.update_challenge_phase_config(challenge_phase)

if success:
logger.info(f"Successfully synced challenge phase {challenge_phase_id} to GitHub")
return True
else:
logger.error(f"Failed to sync challenge phase {challenge_phase_id} to GitHub")
return False

except ChallengePhase.DoesNotExist:
logger.error(f"Challenge phase {challenge_phase_id} not found")
return False
except Exception as e:
logger.error(
"Github Sync Challenge Phase unsuccessful due to {}".format(e)
)
logger.error(f"Error syncing challenge phase {challenge_phase_id} to GitHub: {str(e)}")
return False
18 changes: 18 additions & 0 deletions apps/challenges/migrations/0115_add_last_github_sync_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.20 on 2025-08-23 19:02

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('challenges', '0114_add_github_token_field'),
]

operations = [
migrations.AddField(
model_name='challenge',
name='last_github_sync',
field=models.DateTimeField(blank=True, null=True),
),
]
37 changes: 31 additions & 6 deletions apps/challenges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ def __init__(self, *args, **kwargs):
github_token = models.CharField(
max_length=200, null=True, blank=True, default=""
)
# Simple sync tracking
last_github_sync = models.DateTimeField(null=True, blank=True)
# The number of vCPU for a Fargate worker for the challenge. Default value
# is 0.25 vCPU.
worker_cpu_cores = models.IntegerField(null=True, blank=True, default=512)
Expand Down Expand Up @@ -315,22 +317,41 @@ def update_sqs_retention_period_for_challenge(
if not created and is_model_field_changed(instance, field_name):
serialized_obj = serializers.serialize("json", [instance])
aws.update_sqs_retention_period_task.delay(serialized_obj)
# Update challenge
# Update challenge - but prevent signal recursion
curr = getattr(instance, "{}".format(field_name))
challenge = instance
challenge._original_sqs_retention_period = curr
challenge.save()
# Use update() to avoid triggering signals again
Challenge.objects.filter(id=challenge.id).update(_original_sqs_retention_period=curr)


@receiver(signals.post_save, sender="challenges.Challenge")
def challenge_details_sync(sender, instance, created, **kwargs):
"""Sync challenge details to GitHub when challenge is updated"""
# Prevent recursive calls by checking if this is a signal-triggered save
if hasattr(instance, '_signal_save_in_progress'):
logger.info(f"Skipping GitHub sync for challenge {instance.id} - signal save already in progress")
return

logger.info(f"Challenge signal triggered: created={created}, id={instance.id}, github_repo={instance.github_repository}, github_token={'YES' if instance.github_token else 'NO'}")
if not created and instance.github_token and instance.github_repository:
try:
from challenges.github_utils import sync_challenge_to_github
sync_challenge_to_github(instance)
# Mark that we're doing a signal-triggered operation
instance._signal_save_in_progress = True

from challenges.github_utils import github_challenge_sync
logger.info(f"Starting GitHub sync for challenge {instance.id}")
github_challenge_sync(instance.id)

# Clear the flag
delattr(instance, '_signal_save_in_progress')
except Exception as e:
logger.error(f"Error in challenge_details_sync: {str(e)}")
# Clear the flag even on error
if hasattr(instance, '_signal_save_in_progress'):
delattr(instance, '_signal_save_in_progress')
else:
logger.info(f"Skipping GitHub sync: created={created}, has_token={bool(instance.github_token)}, has_repo={bool(instance.github_repository)}")


class DatasetSplit(TimeStampedModel):
Expand Down Expand Up @@ -462,12 +483,16 @@ def save(self, *args, **kwargs):
@receiver(signals.post_save, sender="challenges.ChallengePhase")
def challenge_phase_details_sync(sender, instance, created, **kwargs):
"""Sync challenge phase details to GitHub when challenge phase is updated"""
logger.info(f"ChallengePhase signal triggered: created={created}, id={instance.id}, challenge_id={instance.challenge.id}")
if not created and instance.challenge.github_token and instance.challenge.github_repository:
try:
from challenges.github_utils import sync_challenge_phase_to_github
sync_challenge_phase_to_github(instance)
from challenges.github_utils import github_challenge_phase_sync
logger.info(f"Starting GitHub sync for challenge phase {instance.id}")
github_challenge_phase_sync(instance.id)
except Exception as e:
logger.error(f"Error in challenge_phase_details_sync: {str(e)}")
else:
logger.info(f"Skipping GitHub sync: created={created}, challenge_has_token={bool(instance.challenge.github_token)}, challenge_has_repo={bool(instance.challenge.github_repository)}")


def post_save_connect(field_name, sender):
Expand Down
3 changes: 1 addition & 2 deletions apps/challenges/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
name="get_challenge_detail",
),
url(
r"^(?P<challenge_pk>[0-9]+)/participant_team/team_detail$",
r"^challenge/(?P<challenge_pk>[0-9]+)/participant_team/team_detail$",
views.participant_team_detail_for_challenge,
name="participant_team_detail_for_challenge",
),
Expand All @@ -38,7 +38,6 @@
views.challenge_phase_detail,
name="get_challenge_phase_detail",
),
# `A-Za-z` because it accepts either of `all, future, past or present` in either case
url(
r"^challenge/(?P<challenge_time>[A-Za-z]+)/(?P<challenge_approved>[A-Za-z]+)/(?P<challenge_published>[A-Za-z]+)$",
views.get_all_challenges,
Expand Down
Loading