Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5cdbeb2
Add project_health_metrics node and query
ahmedxgouda Jun 10, 2025
d5b9dcb
Add health field to the ProjectNode that represents sll ProjectHealth…
ahmedxgouda Jun 10, 2025
6875616
Add tests
ahmedxgouda Jun 10, 2025
bd26456
Update filtering and add fields to models
ahmedxgouda Jun 11, 2025
fe32441
Update filtering
ahmedxgouda Jun 11, 2025
2fe6ff7
Update tests
ahmedxgouda Jun 11, 2025
1b9bd60
Save new boolean values
ahmedxgouda Jun 11, 2025
046b226
Add boolean mapping
ahmedxgouda Jun 12, 2025
76a05e4
Add query tests
ahmedxgouda Jun 12, 2025
063c0fe
Merge migrations
ahmedxgouda Jun 13, 2025
25d6c3b
Update filtering, add migrations, and update scripts
ahmedxgouda Jun 13, 2025
0d0d573
Update tests and queries
ahmedxgouda Jun 13, 2025
e97791e
Add test with filters
ahmedxgouda Jun 13, 2025
3a935b9
Update filtering
ahmedxgouda Jun 13, 2025
26c5ce7
Update tests
ahmedxgouda Jun 15, 2025
c749700
Merge migrations
ahmedxgouda Jun 15, 2025
8c0173b
Merge branch 'main' into dashboard/graphql-health-queries
ahmedxgouda Jun 17, 2025
dac0d22
Revert unnecessary work and apply suggestions
ahmedxgouda Jun 17, 2025
4442be0
Remove has_no_recent_commits from project
ahmedxgouda Jun 17, 2025
90bab4f
Add missing fields for FE query
ahmedxgouda Jun 17, 2025
019f614
Remove project name from the test
ahmedxgouda Jun 17, 2025
9ff593e
Merge branch 'main' into dashboard/graphql-health-queries
ahmedxgouda Jun 17, 2025
2b27698
Merge branch 'main' into dashboard/graphql-health-queries
ahmedxgouda Jun 17, 2025
9caedaa
Merge branch 'main' into dashboard/graphql-health-queries
ahmedxgouda Jun 18, 2025
ade2191
Clean migrations
ahmedxgouda Jun 18, 2025
d7dfdf0
Update code
arkid15r Jun 18, 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
7 changes: 7 additions & 0 deletions backend/apps/owasp/graphql/nodes/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from apps.github.graphql.nodes.release import ReleaseNode
from apps.github.graphql.nodes.repository import RepositoryNode
from apps.owasp.graphql.nodes.common import GenericEntityNode
from apps.owasp.graphql.nodes.project_health_metrics import ProjectHealthMetricsNode
from apps.owasp.models.project import Project
from apps.owasp.models.project_health_metrics import ProjectHealthMetrics

RECENT_ISSUES_LIMIT = 5
RECENT_RELEASES_LIMIT = 5
Expand All @@ -34,6 +36,11 @@
class ProjectNode(GenericEntityNode):
"""Project node."""

@strawberry.field
def health(self) -> list[ProjectHealthMetricsNode]:
"""Resolve project health metrics."""
return ProjectHealthMetrics.objects.filter(project=self).order_by("-nest_created_at")

@strawberry.field
def issues_count(self) -> int:
"""Resolve issues count."""
Expand Down
55 changes: 55 additions & 0 deletions backend/apps/owasp/graphql/nodes/project_health_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""OWASP Project Health Metrics Node."""

import strawberry
import strawberry_django

from apps.owasp.models.project_health_metrics import ProjectHealthMetrics


@strawberry_django.type(
ProjectHealthMetrics,
fields=[
"contributors_count",
"forks_count",
"is_funding_requirements_compliant",
"is_leader_requirements_compliant",
"open_issues_count",
"recent_releases_count",
"score",
"stars_count",
"unanswered_issues_count",
"unassigned_issues_count",
],
)
class ProjectHealthMetricsNode:
"""Project health metrics node."""

@strawberry.field
def age_days(self) -> int:
"""Resolve project age in days."""
return self.age_days

@strawberry.field
def last_commit_days(self) -> int:
"""Resolve last commit age in days."""
return self.last_commit_days

@strawberry.field
def last_pull_request_days(self) -> int:
"""Resolve last pull request age in days."""
return self.last_pull_request_days

@strawberry.field
def last_release_days(self) -> int:
"""Resolve last release age in days."""
return self.last_release_days

Comment on lines +28 to +47
Copy link
Contributor

@coderabbitai coderabbitai bot Jun 10, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Infinite recursion in all resolver methods

Each resolver returns the same attribute name it is defining, causing a self-referential lookup that yields the bound method instead of the computed value and eventually raises RecursionError at runtime.

-    def age_days(self) -> int:
-        return self.age_days
+    def age_days(self) -> int:
+        # `self` is a dataclass proxy to the underlying Django instance
+        return self._root.age_days  # `_root` is provided by strawberry-django

Apply the same pattern (or self._root.<prop>) to last_commit_days, last_pull_request_days, last_release_days, and owasp_page_last_update_days.

Alternatively, expose these fields via fields=[…] and drop custom resolvers entirely.

🤖 Prompt for AI Agents
In backend/apps/owasp/graphql/nodes/project_health_metrics.py around lines 27 to
46, the resolver methods for age_days, last_commit_days, last_pull_request_days,
and last_release_days cause infinite recursion by returning the same attribute
name they define. To fix this, change each resolver to return the corresponding
value from the underlying data source, such as using self._root.age_days,
self._root.last_commit_days, self._root.last_pull_request_days, and
self._root.last_release_days instead of returning self.age_days, etc.
Alternatively, remove these custom resolvers and expose these fields directly
via the fields parameter.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@ahmedxgouda have you checked if this suggestion is valid?

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No, it is not. Even strawberry type does not have this attribute.

@strawberry.field
def owasp_page_last_update_days(self) -> int:
"""Resolve OWASP page last update age in days."""
return self.owasp_page_last_update_days

@strawberry.field
def project_name(self) -> str:
"""Resolve project node."""
return self.project.name
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

project_name resolver accesses a non-existent attribute

self.project is not part of the exposed fields=[…] list, so it is not copied onto the dataclass instance. Accessing it will raise AttributeError.
Use the root model instead:

-    def project_name(self) -> str:
-        """Resolve project node."""
-        return self.project.name
+    def project_name(self) -> str:
+        """Resolve project name."""
+        return self._root.project.name
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@strawberry.field
def owasp_page_last_update_days(self) -> int:
"""Resolve OWASP page last update age in days."""
return self.owasp_page_last_update_days
@strawberry.field
def project_name(self) -> str:
"""Resolve project node."""
return self.project.name
@strawberry.field
def owasp_page_last_update_days(self) -> int:
"""Resolve OWASP page last update age in days."""
return self.owasp_page_last_update_days
@strawberry.field
def project_name(self) -> str:
"""Resolve project name."""
return self._root.project.name
🧰 Tools
🪛 Pylint (3.3.7)

[error] 55-55: Instance of 'ProjectHealthMetricsNode' has no 'project' member

(E1101)

🤖 Prompt for AI Agents
In backend/apps/owasp/graphql/nodes/project_health_metrics.py around lines 47 to
55, the project_name resolver tries to access self.project which is not included
in the dataclass fields and thus not available, causing an AttributeError. To
fix this, modify the resolver to access the project name via the root model
object that contains the original data, ensuring the attribute exists and can be
accessed safely.

2 changes: 2 additions & 0 deletions backend/apps/owasp/graphql/queries/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .event import EventQuery
from .post import PostQuery
from .project import ProjectQuery
from .project_health_metrics import ProjectHealthMetricsQuery
from .snapshot import SnapshotQuery
from .sponsor import SponsorQuery
from .stats import StatsQuery
Expand All @@ -16,6 +17,7 @@ class OwaspQuery(
EventQuery,
PostQuery,
ProjectQuery,
ProjectHealthMetricsQuery,
SnapshotQuery,
SponsorQuery,
StatsQuery,
Expand Down
82 changes: 82 additions & 0 deletions backend/apps/owasp/graphql/queries/project_health_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""OWASP Project Health Metrics Queries."""

import strawberry

from apps.owasp.graphql.nodes.project_health_metrics import ProjectHealthMetricsNode
from apps.owasp.models.project_health_metrics import ProjectHealthMetrics

# It is stated in issue #711
CONTRIBUTORS_COUNT_REQUIREMENT = 2


@strawberry.type
class ProjectHealthMetricsQuery:
"""Project health metrics queries."""

@strawberry.field
def unhealthy_projects(
self,
*,
is_contributors_requirement_compliant: bool | None = None,
is_funding_requirements_compliant: bool | None = None,
has_no_recent_commits: bool | None = None,
has_recent_releases: bool | None = None,
is_leader_requirements_compliant: bool | None = None,
limit: int = 20,
has_long_open_issues: bool | None = None,
has_long_unanswered_issues: bool | None = None,
has_long_unassigned_issues: bool | None = None,
# Because the default behavior is to return unhealthy projects with low scores,
# we set `low_score` to True by default.
# We may return projects with high scores to indicate issues that need attention,
# like a lack of contributors, recent commits, or otherwise.
# We set the default of other parameters to None,
# to allow retrieving all unhealthy projects without any filters.
has_low_score: bool = True,
) -> list[ProjectHealthMetricsNode]:
"""Resolve unhealthy projects."""
filters = {}

if has_recent_releases is not None:
if has_recent_releases:
filters["recent_releases_count__gt"] = 0
else:
filters["recent_releases_count"] = 0

if is_contributors_requirement_compliant is not None:
suffix = "__gte" if is_contributors_requirement_compliant else "__lt"
filters[f"contributors_count{suffix}"] = CONTRIBUTORS_COUNT_REQUIREMENT

if has_no_recent_commits is not None:
filters["has_no_recent_commits"] = has_no_recent_commits

if has_long_open_issues is not None:
filters["has_long_open_issues"] = has_long_open_issues

if has_long_unanswered_issues is not None:
filters["has_long_unanswered_issues"] = has_long_unanswered_issues

if has_long_unassigned_issues is not None:
filters["has_long_unassigned_issues"] = has_long_unassigned_issues

if is_leader_requirements_compliant is not None:
filters["is_leader_requirements_compliant"] = is_leader_requirements_compliant

if is_funding_requirements_compliant is not None:
filters["is_funding_requirements_compliant"] = is_funding_requirements_compliant

if has_low_score:
filters["score__lt"] = 50

# Get the last created metrics (one for each project)
queryset = (
ProjectHealthMetrics.objects.select_related("project")
.order_by("project__key", "-nest_created_at")
.distinct("project__key")
)

# Apply filters
if filters:
queryset = queryset.filter(**filters)

return queryset.select_related("project")[:limit]
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def handle(self, *args, **options):
"contributors_count": "contributors_count",
"created_at": "created_at",
"forks_count": "forks_count",
"has_no_recent_commits": "has_no_recent_commits",
"is_funding_requirements_compliant": "is_funding_requirements_compliant",
"is_leader_requirements_compliant": "is_leader_requirements_compliant",
"last_committed_at": "pushed_at",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ def handle(self, *args, **options):
"unassigned_issues_count": 6.0,
}

boolean_mapping = {
"open_issues_count": "has_long_open_issues",
"unanswered_issues_count": "has_long_unanswered_issues",
"unassigned_issues_count": "has_long_unassigned_issues",
}
project_health_metrics = []
project_health_requirements = {
phr.level: phr for phr in ProjectHealthRequirements.objects.all()
Expand All @@ -56,11 +61,21 @@ def handle(self, *args, **options):
for field, weight in backward_fields.items():
if int(getattr(metric, field)) <= int(getattr(requirements, field)):
score += weight
elif field in boolean_mapping:
setattr(metric, boolean_mapping[field], True)

metric.score = score
project_health_metrics.append(metric)

ProjectHealthMetrics.bulk_save(project_health_metrics, fields=["score"])
ProjectHealthMetrics.bulk_save(
project_health_metrics,
fields=[
"score",
"has_long_open_issues",
"has_long_unanswered_issues",
"has_long_unassigned_issues",
],
)
self.stdout.write(
self.style.SUCCESS("Updated projects health metrics score successfully.")
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 5.2.1 on 2025-06-11 15:55

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("owasp", "0037_alter_projecthealthmetrics_project"),
]

operations = [
migrations.AddField(
model_name="projecthealthmetrics",
name="has_long_open_issues",
field=models.BooleanField(default=False, verbose_name="Has long open issues"),
),
migrations.AddField(
model_name="projecthealthmetrics",
name="has_long_unanswered_issues",
field=models.BooleanField(default=False, verbose_name="Has long unanswered issues"),
),
migrations.AddField(
model_name="projecthealthmetrics",
name="has_long_unassigned_issues",
field=models.BooleanField(default=False, verbose_name="Has long unassigned issues"),
),
migrations.AddField(
model_name="projecthealthmetrics",
name="has_no_recent_commits",
field=models.BooleanField(default=False, verbose_name="Has no recent commits"),
),
]
12 changes: 12 additions & 0 deletions backend/apps/owasp/migrations/0041_merge_20250613_0336.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Generated by Django 5.2.1 on 2025-06-13 03:36

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("owasp", "0038_projecthealthmetrics_has_long_open_issues_and_more"),
("owasp", "0040_alter_projecthealthmetrics_is_leader_requirements_compliant"),
]

operations = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.2.1 on 2025-06-13 05:54

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("owasp", "0041_merge_20250613_0336"),
]

operations = [
migrations.RemoveField(
model_name="projecthealthmetrics",
name="has_no_recent_commits",
),
migrations.AddField(
model_name="projecthealthmetrics",
name="has_recent_commits",
field=models.BooleanField(default=False, verbose_name="Has recent commits"),
),
]
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Field swap drops data and triggers 2 full-table rewrites – replace with atomic rename + data copy

RemoveField(has_no_recent_commits) → AddField(has_recent_commits) discards every existing value and rewrites the table twice (once for the drop, once for the add). In production this will:

  1. Nullify historical metrics for thousands of rows.
  2. Generate avoidable downtime on large tables.

Prefer a single RenameField combined with a RunPython step that inverts the value, e.g.:

- migrations.RemoveField(
-     model_name="projecthealthmetrics",
-     name="has_no_recent_commits",
- ),
- migrations.AddField(
-     model_name="projecthealthmetrics",
-     name="has_recent_commits",
-     field=models.BooleanField(default=False, verbose_name="Has recent commits"),
- ),
+ migrations.RenameField(
+     model_name="projecthealthmetrics",
+     old_name="has_no_recent_commits",
+     new_name="has_recent_commits",
+ ),
+ migrations.RunPython(
+     code=lambda apps, _: apps.get_model("owasp", "ProjectHealthMetrics")
+         .objects.update(has_recent_commits=models.F("has_recent_commits").bitwise_not()),
+     reverse_code=migrations.RunPython.noop,
+ )

This keeps the data, performs one table rewrite, and documents intent.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
operations = [
migrations.RemoveField(
model_name="projecthealthmetrics",
name="has_no_recent_commits",
),
migrations.AddField(
model_name="projecthealthmetrics",
name="has_recent_commits",
field=models.BooleanField(default=False, verbose_name="Has recent commits"),
),
]
operations = [
migrations.RenameField(
model_name="projecthealthmetrics",
old_name="has_no_recent_commits",
new_name="has_recent_commits",
),
migrations.RunPython(
code=lambda apps, _: apps
.get_model("owasp", "ProjectHealthMetrics")
.objects
.update(has_recent_commits=models.F("has_recent_commits").bitwise_not()),
reverse_code=migrations.RunPython.noop,
),
]
🤖 Prompt for AI Agents
In
backend/apps/owasp/migrations/0042_remove_projecthealthmetrics_has_no_recent_commits_and_more.py
around lines 11 to 21, the current migration removes the field
has_no_recent_commits and adds has_recent_commits, causing data loss and two
full-table rewrites. To fix this, replace the RemoveField and AddField with a
single RenameField operation to rename has_no_recent_commits to
has_recent_commits, then add a RunPython migration step that inverts the boolean
values to preserve the correct semantics. This approach keeps existing data
intact, reduces table rewrites to one, and clearly documents the data
transformation intent.

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.2.1 on 2025-06-13 07:44

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("owasp", "0042_remove_projecthealthmetrics_has_no_recent_commits_and_more"),
]

operations = [
migrations.RemoveField(
model_name="projecthealthmetrics",
name="has_recent_commits",
),
migrations.AddField(
model_name="projecthealthmetrics",
name="has_no_recent_commits",
field=models.BooleanField(default=False, verbose_name="Has no recent commits"),
),
]
12 changes: 12 additions & 0 deletions backend/apps/owasp/migrations/0044_merge_20250615_0346.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Generated by Django 5.2.1 on 2025-06-15 03:46

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("owasp", "0042_alter_projecthealthmetrics_score"),
("owasp", "0043_remove_projecthealthmetrics_has_recent_commits_and_more"),
]

operations = []
6 changes: 6 additions & 0 deletions backend/apps/owasp/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ def __str__(self) -> str:
"""Project human readable representation."""
return f"{self.name or self.key}"

@property
def has_no_recent_commits(self) -> bool:
"""Indicate whether project has no recent commits."""
recent_period = timezone.now() - datetime.timedelta(days=60)
return self.pushed_at is None or self.pushed_at < recent_period or self.commits_count == 0

@property
def is_code_type(self) -> bool:
"""Indicate whether project has CODE type."""
Expand Down
12 changes: 12 additions & 0 deletions backend/apps/owasp/models/project_health_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ class Meta:
contributors_count = models.PositiveIntegerField(verbose_name="Contributors", default=0)
created_at = models.DateTimeField(verbose_name="Created at", blank=True, null=True)
forks_count = models.PositiveIntegerField(verbose_name="Forks", default=0)

# The next boolean fields are used for filtering
has_no_recent_commits = models.BooleanField(
verbose_name="Has no recent commits", default=False
)
has_long_open_issues = models.BooleanField(verbose_name="Has long open issues", default=False)
has_long_unanswered_issues = models.BooleanField(
verbose_name="Has long unanswered issues", default=False
)
has_long_unassigned_issues = models.BooleanField(
verbose_name="Has long unassigned issues", default=False
)
is_funding_requirements_compliant = models.BooleanField(
verbose_name="Is funding requirements compliant", default=False
)
Expand Down
Loading