Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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
13 changes: 12 additions & 1 deletion backend/apps/owasp/graphql/nodes/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,25 @@ class ProjectNode(GenericEntityNode):
"""Project node."""

@strawberry.field
def health_metrics(self, limit: int = 30) -> list[ProjectHealthMetricsNode]:
def health_metrics_list(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 health_metrics_latest(self) -> ProjectHealthMetricsNode | None:
"""Resolve latest project health metrics."""
return (
ProjectHealthMetrics.get_latest_health_metrics()
.filter(
project=self,
)
.first()
)

@strawberry.field
def issues_count(self) -> int:
"""Resolve issues count."""
Expand Down
17 changes: 17 additions & 0 deletions backend/apps/owasp/graphql/nodes/project_health_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"recent_releases_count",
"score",
"stars_count",
"total_issues_count",
"total_releases_count",
"unanswered_issues_count",
"unassigned_issues_count",
],
Expand All @@ -35,6 +37,11 @@ def age_days(self) -> int:
"""Resolve project age in days."""
return self.age_days

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

@strawberry.field
def created_at(self) -> datetime:
"""Resolve metrics creation date."""
Expand All @@ -55,6 +62,11 @@ def last_pull_request_days(self) -> int:
"""Resolve last pull request age in days."""
return self.last_pull_request_days

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

@strawberry.field
def last_release_days(self) -> int:
"""Resolve last release age in days."""
Expand All @@ -74,3 +86,8 @@ def project_name(self) -> str:
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 owasp_page_last_update_days_requirement(self) -> int:
"""Resolve OWASP page last update age requirement in days."""
return self.owasp_page_last_update_days_requirement
15 changes: 15 additions & 0 deletions backend/apps/owasp/models/project_health_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ def age_days(self) -> int:
"""Calculate project age in days."""
return (timezone.now() - self.created_at).days if self.created_at else 0

@property
def age_days_requirement(self) -> int:
"""Get the age requirement for the project."""
return self.project_requirements.age_days

@property
def last_commit_days(self) -> int:
"""Calculate days since last commit."""
Expand All @@ -106,6 +111,11 @@ def last_pull_request_days(self) -> int:
else 0
)

@property
def last_pull_request_days_requirement(self) -> int:
"""Get the last pull request requirement for the project."""
return self.project_requirements.last_pull_request_days

@property
def last_release_days(self) -> int:
"""Calculate days since last release."""
Expand All @@ -125,6 +135,11 @@ def owasp_page_last_update_days(self) -> int:
else 0
)

@property
def owasp_page_last_update_days_requirement(self) -> int:
"""Get the OWASP page last update requirement for the project."""
return self.project_requirements.owasp_page_last_update_days

@property
def project_requirements(self) -> ProjectHealthRequirements:
"""Get the project health requirements for the project's level."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def test_meta_configuration(self):
"recent_releases_count",
"score",
"stars_count",
"total_issues_count",
"total_releases_count",
"unanswered_issues_count",
"unassigned_issues_count",
}
Expand Down Expand Up @@ -67,8 +69,10 @@ def _get_field_by_name(self, name):
("open_pull_requests_count", int),
("owasp_page_last_update_days", int),
("project_name", str),
("stars_count", int),
("recent_releases_count", int),
("stars_count", int),
("total_issues_count", int),
("total_releases_count", int),
("unanswered_issues_count", int),
("unassigned_issues_count", int),
],
Expand Down
9 changes: 7 additions & 2 deletions backend/tests/apps/owasp/graphql/nodes/project_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ 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")
def test_resolve_health_metrics_latest(self):
field = self._get_field_by_name("health_metrics_latest")
assert field is not None
assert field.type.of_type is ProjectHealthMetricsNode
Comment on lines +47 to +50
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Verify the expected return type for health_metrics_latest field.

The test uses field.type.of_type which is typically used for list types, but the field name health_metrics_latest suggests it should return a single ProjectHealthMetricsNode object, not a list.

If health_metrics_latest returns a single object, the assertion should be:

-        assert field.type.of_type is ProjectHealthMetricsNode
+        assert field.type is ProjectHealthMetricsNode

If it returns a list, then the current assertion is correct.


🏁 Script executed:

#!/bin/bash
# Description: Check the actual field definition in the ProjectNode to determine the correct return type
# Expected: Find the field definition and see if it's a list or single object

ast-grep --pattern 'health_metrics_latest'

Length of output: 161


Incorrect assertion of health_metrics_latest return type

The health_metrics_latest field is defined to return a single ProjectHealthMetricsNode (or null), not a list. The test should assert the field’s type directly, not its element type.

File: backend/tests/apps/owasp/graphql/nodes/project_test.py (lines 47–50)

-        assert field.type.of_type is ProjectHealthMetricsNode
+        assert field.type is ProjectHealthMetricsNode
📝 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_resolve_health_metrics_latest(self):
field = self._get_field_by_name("health_metrics_latest")
assert field is not None
assert field.type.of_type is ProjectHealthMetricsNode
def test_resolve_health_metrics_latest(self):
field = self._get_field_by_name("health_metrics_latest")
assert field is not None
assert field.type is ProjectHealthMetricsNode
🤖 Prompt for AI Agents
In backend/tests/apps/owasp/graphql/nodes/project_test.py around lines 47 to 50,
the test incorrectly asserts that the health_metrics_latest field returns a list
by checking field.type.of_type. Instead, update the assertion to check
field.type directly against ProjectHealthMetricsNode, reflecting that the field
returns a single node or null, not a list.


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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def test_resolve_project_health_metrics(self):
is_funding_requirements_compliant=True,
is_leader_requirements_compliant=True,
recent_releases_count=3,
total_issues_count=15,
total_releases_count=5,
)
]
query = ProjectHealthMetricsQuery(project_health_metrics=metrics)
Expand Down
2 changes: 1 addition & 1 deletion frontend/__tests__/unit/data/mockProjectDetailsData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export const mockProjectDetailsData = {
project: {
contributorsCount: 1200,
forksCount: 10,
healthMetrics: [
healthMetricsList: [
{
openIssuesCount: 5,
unassignedIssuesCount: 2,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/projects/[projectKey]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const ProjectDetailsPage = () => {
<DetailsCard
details={projectDetails}
entityKey={project.key}
healthMetricsData={project.healthMetrics}
healthMetricsData={project.healthMetricsList}
isActive={project.isActive}
languages={project.languages}
pullRequests={project.recentPullRequests}
Expand Down
153 changes: 153 additions & 0 deletions frontend/src/app/projects/dashboard/metrics/[projectKey]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use client'

import { useQuery } from '@apollo/client'
import {
faPeopleGroup,
faCodeFork,
faDollar,
faCodePullRequest,
faChartArea,
faExclamationCircle,
faQuestionCircle,
faFolderOpen,
faHandshake,
faTag,
faRocket,
faStar,
faTags,
} from '@fortawesome/free-solid-svg-icons'
import millify from 'millify'
import { useParams } from 'next/navigation'
import { FC, useState, useEffect } from 'react'
import { handleAppError } from 'app/global-error'
import { GET_PROJECT_HEALTH_METRICS_DETAILS } from 'server/queries/projectsHealthDashboardQueries'
import { HealthMetricsProps } from 'types/healthMetrics'
import BarChart from 'components/BarChart'
import DashboardCard from 'components/DashboardCard'
import GeneralCompliantComponent from 'components/GeneralCompliantComponent'
import LoadingSpinner from 'components/LoadingSpinner'
import MetricsScoreCircle from 'components/MetricsScoreCircle'
const ProjectHealthMetricsDetails: FC = () => {
const { projectKey } = useParams()
const [metrics, setMetrics] = useState<HealthMetricsProps>()
const {
loading,
error: graphqlError,
data,
} = useQuery(GET_PROJECT_HEALTH_METRICS_DETAILS, {
variables: { projectKey },
})

useEffect(() => {
if (graphqlError) {
handleAppError(graphqlError)
}
if (data?.project?.healthMetricsLatest) {
setMetrics(data.project.healthMetricsLatest)
}
}, [graphqlError, data])
if (loading) {
return <LoadingSpinner />
}

return (
<div className="flex flex-col gap-4">
{metrics && (
<>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{metrics.projectName}</h1>
<MetricsScoreCircle score={metrics.score} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<GeneralCompliantComponent
title="Funding Requirements Compliant"
icon={faDollar}
compliant={metrics.isFundingRequirementsCompliant}
/>
<GeneralCompliantComponent
title="Leader Requirements Compliant"
icon={faHandshake}
compliant={metrics.isLeaderRequirementsCompliant}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<DashboardCard title="Stars" icon={faStar} stats={millify(metrics.starsCount)} />
<DashboardCard title="Forks" icon={faCodeFork} stats={millify(metrics.forksCount)} />
<DashboardCard
title="Contributors"
icon={faPeopleGroup}
stats={millify(metrics.contributorsCount)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
<DashboardCard
title="Open Issues"
icon={faExclamationCircle}
stats={millify(metrics.openIssuesCount)}
/>
<DashboardCard
title="Total Issues"
icon={faFolderOpen}
stats={millify(metrics.totalIssuesCount)}
/>
<DashboardCard
title="Unassigned Issues"
icon={faTag}
stats={millify(metrics.unassignedIssuesCount)}
/>
<DashboardCard
title="Unanswered Issues"
icon={faQuestionCircle}
stats={millify(metrics.unansweredIssuesCount)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<DashboardCard
title="Open Pull Requests"
icon={faCodePullRequest}
stats={millify(metrics.openPullRequestsCount)}
/>
<DashboardCard
title="Recent Releases"
icon={faRocket}
stats={millify(metrics.recentReleasesCount)}
/>
<DashboardCard
title="Total Releases"
icon={faTags}
stats={millify(metrics.totalReleasesCount)}
/>
</div>
<BarChart
title="Days Metrics"
icon={faChartArea}
labels={[
'Project Age',
'Days Since Last Commit',
'Days Since Last Release',
'Days Since Last Pull Request',
'Days Since OWASP Page Last Update',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we add a tooltip to this to show the full text when it gets truncated?
Screenshot 2025-07-18 at 5 16 41 PM

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This text is a part of the apexcharts Chart component. So, I don't have full control on it. Maybe it can be done, I will take a look.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@ahmedxgouda Gotcha! I'll check that too! Thank you!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@kasya Didn't find a way, but a tooltip will show when the user hover on the bar itself.

]}
days={[
metrics.ageDays,
metrics.lastCommitDays,
metrics.lastReleaseDays,
metrics.lastPullRequestDays,
metrics.owaspPageLastUpdateDays,
]}
requirements={[
metrics.ageDaysRequirement,
metrics.lastCommitDaysRequirement,
metrics.lastReleaseDaysRequirement,
metrics.lastPullRequestDaysRequirement,
metrics.owaspPageLastUpdateDaysRequirement,
]}
reverseColors={[true, false, false, false, false]}
/>
</>
)}
</div>
)
}

export default ProjectHealthMetricsDetails
11 changes: 10 additions & 1 deletion frontend/src/components/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const BarChart: React.FC<{
labels: string[]
days: number[]
requirements: number[]
}> = ({ title, days, icon, requirements, labels }) => {
reverseColors?: boolean[]
}> = ({ title, days, icon, requirements, labels, reverseColors }) => {
const { theme } = useTheme()
let themeColor = '#1E1E2C'
let redColor = '#FF7875'
Expand Down Expand Up @@ -80,6 +81,14 @@ const BarChart: React.FC<{
colors: [
function ({ value, dataPointIndex, _ }) {
const requirement = requirements[dataPointIndex]
if (reverseColors && reverseColors[dataPointIndex]) {
if (value < requirement * 0.75) {
return orangeColor
} else if (value < requirement) {
return redColor
}
return greenColor
}
if (value > requirement) {
return redColor
} else if (value > requirement * 0.75) {
Expand Down
30 changes: 2 additions & 28 deletions frontend/src/components/CardDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import ChapterMapWrapper from 'components/ChapterMapWrapper'
import HealthMetrics from 'components/HealthMetrics'
import InfoBlock from 'components/InfoBlock'
import LeadersList from 'components/LeadersList'
import MetricsScoreCircle from 'components/MetricsScoreCircle'
import Milestones from 'components/Milestones'
import RecentIssues from 'components/RecentIssues'
import RecentPullRequests from 'components/RecentPullRequests'
Expand Down Expand Up @@ -54,15 +55,6 @@ const DetailsCard = ({
type,
userSummary,
}: DetailsCardProps) => {
let scoreStyle = 'bg-green-400 text-green-900'
if (type === 'project' && healthMetricsData.length > 0) {
const score = healthMetricsData[0].score
if (score < 50) {
scoreStyle = 'bg-red-400 text-red-900'
} else if (score < 75) {
scoreStyle = 'bg-yellow-400 text-yellow-900'
}
}
return (
<div className="min-h-screen bg-white p-8 text-gray-600 dark:bg-[#212529] dark:text-gray-300">
<div className="mx-auto max-w-6xl">
Expand All @@ -71,25 +63,7 @@ const DetailsCard = ({
<h1 className="text-4xl font-bold">{title}</h1>
{IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && (
<Link href="#issues-trend">
<div
className={`group relative flex h-20 w-20 flex-col items-center justify-center rounded-full border-2 shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl ${scoreStyle}`}
>
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-white/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"></div>
<div className="relative z-10 flex flex-col items-center text-center">
<span className="text-xs font-semibold uppercase tracking-wide opacity-90">
Health
</span>
<span className="text-xl font-black leading-none">
{healthMetricsData[0].score}
</span>
<span className="text-xs font-semibold uppercase tracking-wide opacity-90">
Score
</span>
</div>
{healthMetricsData[0].score < 30 && (
<div className="animate-pulse absolute inset-0 rounded-full bg-red-400/20"></div>
)}
</div>
<MetricsScoreCircle score={healthMetricsData[0].score} />
</Link>
)}
</div>
Expand Down
Loading