Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 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,15 @@
class ProjectNode(GenericEntityNode):
"""Project node."""

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

@strawberry.field
def issues_count(self) -> int:
"""Resolve issues count."""
Expand Down
51 changes: 51 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,51 @@
"""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",
"open_pull_requests_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
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ def handle(self, *args, **options):
metric.score = score
project_health_metrics.append(metric)

ProjectHealthMetrics.bulk_save(project_health_metrics, fields=["score"])
ProjectHealthMetrics.bulk_save(
project_health_metrics,
fields=[
"score",
],
)
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,69 @@
"""Test cases for ProjectHealthMetricsNode."""

import pytest

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


class TestProjectHealthMetricsNode:
def test_project_health_metrics_node_inheritance(self):
assert hasattr(ProjectHealthMetricsNode, "__strawberry_definition__")

def test_meta_configuration(self):
field_names = {
field.name for field in ProjectHealthMetricsNode.__strawberry_definition__.fields
}
expected_field_names = {
"age_days",
"contributors_count",
"forks_count",
"is_funding_requirements_compliant",
"is_leader_requirements_compliant",
"last_commit_days",
"last_pull_request_days",
"last_release_days",
"open_issues_count",
"open_pull_requests_count",
"owasp_page_last_update_days",
"recent_releases_count",
"score",
"stars_count",
"unanswered_issues_count",
"unassigned_issues_count",
}
assert expected_field_names.issubset(field_names)
Comment on lines +12 to +34
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Keep assertion & parametrised lists in sync – score covered here but not below

score is verified to exist in expected_field_names but its type is never checked in the parametrised test later on.
Add it to the parametrised table (with the correct type, probably int or float) to avoid blind spots.

@@
             "score",
@@
         assert expected_field_names.issubset(field_names)

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Pylint (3.3.7)

[error] 14-14: Class 'ProjectHealthMetricsNode' has no 'strawberry_definition' member

(E1101)

🤖 Prompt for AI Agents
In backend/tests/apps/owasp/graphql/nodes/project_health_metrics_test.py around
lines 12 to 34, the field "score" is included in the expected_field_names set
but missing from the parametrised test that checks field types. To fix this, add
"score" with its correct type (likely int or float) to the parametrised test
list to ensure its type is validated and keep the assertions consistent.


def _get_field_by_name(self, name):
return next(
(
f
for f in ProjectHealthMetricsNode.__strawberry_definition__.fields
if f.name == name
),
None,
)

@pytest.mark.parametrize(
("field_name", "expected_type"),
[
("age_days", int),
("contributors_count", int),
("forks_count", int),
("is_funding_requirements_compliant", bool),
("is_leader_requirements_compliant", bool),
("last_commit_days", int),
("last_pull_request_days", int),
("last_release_days", int),
("open_issues_count", int),
("open_pull_requests_count", int),
("owasp_page_last_update_days", int),
("stars_count", int),
("recent_releases_count", int),
("unanswered_issues_count", int),
("unassigned_issues_count", int),
],
)
Comment on lines +46 to +65
Copy link
Contributor

@coderabbitai coderabbitai bot Jun 17, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add missing entry and drop duplication risk

See earlier note – add "score" with its expected python type.

@@
             ("stars_count", int),
             ("recent_releases_count", int),
+            ("score", int),  # or float, depending on implementation
             ("unanswered_issues_count", int),
             ("unassigned_issues_count", int),
📝 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
@pytest.mark.parametrize(
("field_name", "expected_type"),
[
("age_days", int),
("contributors_count", int),
("forks_count", int),
("is_funding_requirements_compliant", bool),
("is_leader_requirements_compliant", bool),
("last_commit_days", int),
("last_pull_request_days", int),
("last_release_days", int),
("open_issues_count", int),
("open_pull_requests_count", int),
("owasp_page_last_update_days", int),
("stars_count", int),
("recent_releases_count", int),
("unanswered_issues_count", int),
("unassigned_issues_count", int),
],
)
@pytest.mark.parametrize(
("field_name", "expected_type"),
[
("age_days", int),
("contributors_count", int),
("forks_count", int),
("is_funding_requirements_compliant", bool),
("is_leader_requirements_compliant", bool),
("last_commit_days", int),
("last_pull_request_days", int),
("last_release_days", int),
("open_issues_count", int),
("open_pull_requests_count", int),
("owasp_page_last_update_days", int),
("stars_count", int),
("recent_releases_count", int),
("score", int), # or float, depending on implementation
("unanswered_issues_count", int),
("unassigned_issues_count", int),
],
)
🤖 Prompt for AI Agents
In backend/tests/apps/owasp/graphql/nodes/project_health_metrics_test.py around
lines 46 to 65, the parameterized test list is missing the "score" field and
risks duplication issues. Add the tuple ("score", float) or the appropriate
expected Python type for "score" to the list of field_name and expected_type
pairs to ensure complete test coverage and avoid duplication.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Score could be None, so it is not possible to include within this test because the test checks if it is not None. What do you think? @arkid15r

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!

def test_field_types(self, field_name, expected_type):
field = self._get_field_by_name(field_name)
assert field is not None
assert field.type is expected_type
Comment on lines +66 to +69
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

is identity check can be brittle for Strawberry types

field.type may be wrapped in Annotated, ForwardRef, or Optional.
Using is risks false negatives. Compare structural equality instead:

-        assert field.type is expected_type
+        from typing import get_origin
+
+        actual = get_origin(field.type) or field.type
+        assert actual is expected_type
📝 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
def test_field_types(self, field_name, expected_type):
field = self._get_field_by_name(field_name)
assert field is not None
assert field.type is expected_type
def test_field_types(self, field_name, expected_type):
field = self._get_field_by_name(field_name)
assert field is not None
from typing import get_origin
actual = get_origin(field.type) or field.type
assert actual is expected_type
🤖 Prompt for AI Agents
In backend/tests/apps/owasp/graphql/nodes/project_health_metrics_test.py around
lines 66 to 69, the test uses 'is' to compare field.type and expected_type,
which can cause false negatives due to wrappers like Annotated, ForwardRef, or
Optional. Replace the identity check with a structural equality check, such as
using '==' or an appropriate type comparison method, to ensure the test
accurately verifies the field type regardless of such wrappers.

6 changes: 6 additions & 0 deletions backend/tests/apps/owasp/graphql/nodes/project_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from apps.github.graphql.nodes.release import ReleaseNode
from apps.github.graphql.nodes.repository import RepositoryNode
from apps.owasp.graphql.nodes.project import ProjectNode
from apps.owasp.graphql.nodes.project_health_metrics import ProjectHealthMetricsNode


class TestProjectNode:
Expand Down Expand Up @@ -43,6 +44,11 @@ def _get_field_by_name(self, name):
(f for f in ProjectNode.__strawberry_definition__.fields if f.name == name), None
)

def test_resolve_health_metrics(self):
field = self._get_field_by_name("health_metrics")
assert field is not None
assert field.type.of_type is ProjectHealthMetricsNode

def test_resolve_issues_count(self):
field = self._get_field_by_name("issues_count")
assert field is not None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from apps.owasp.models.project_health_metrics import ProjectHealthMetrics
from apps.owasp.models.project_health_requirements import ProjectHealthRequirements

EXPECTED_SCORE = 52.0
EXPECTED_SCORE = 34.0


class TestUpdateProjectHealthMetricsScoreCommand:
Expand Down Expand Up @@ -41,16 +41,16 @@ def test_handle_successful_update(self):
"forks_count": (5, 6),
"last_release_days": (5, 6),
"last_commit_days": (5, 6),
"open_issues_count": (5, 6),
"open_issues_count": (7, 6),
"open_pull_requests_count": (5, 6),
"owasp_page_last_update_days": (5, 6),
"last_pull_request_days": (5, 6),
"recent_releases_count": (5, 6),
"stars_count": (5, 6),
"total_pull_requests_count": (5, 6),
"total_releases_count": (5, 6),
"unanswered_issues_count": (5, 6),
"unassigned_issues_count": (5, 6),
"unanswered_issues_count": (7, 6),
"unassigned_issues_count": (7, 6),
}

# Create mock metrics with test data
Expand All @@ -73,7 +73,12 @@ def test_handle_successful_update(self):
self.mock_requirements.assert_called_once()

# Check if score was calculated correctly
self.mock_bulk_save.assert_called_once_with([mock_metric], fields=["score"])
self.mock_bulk_save.assert_called_once_with(
[mock_metric],
fields=[
"score",
],
)
assert mock_metric.score == EXPECTED_SCORE
assert "Updated projects health metrics score successfully." in self.stdout.getvalue()
assert "Updating score for project: Test Project" in self.stdout.getvalue()