diff --git a/app.py b/app.py index 9226fe5..d4ba1f9 100644 --- a/app.py +++ b/app.py @@ -421,7 +421,7 @@ class LeaderboardEntry(TypedDict): {"key": "reviews", "label": "PR reviews", "count_label": "review"}, {"key": "prs", "label": "PRs merged", "count_label": "PR"}, {"key": "cycle_lead", "label": "Completed project lead", "count_label": None}, - {"key": "cycle_member", "label": "Completed project member", "count_label": None}, + {"key": "cycle_member", "label": "Completed project contributor", "count_label": None}, ] PRIORITY_BREAKDOWN_KEYS = { diff --git a/constants.py b/constants.py index 1b28ce3..bb7c906 100644 --- a/constants.py +++ b/constants.py @@ -16,5 +16,5 @@ # Points awarded per week for leading a completed cycle project CYCLE_PROJECT_LEAD_POINTS_PER_WEEK = 30 -# Points awarded per week for being a non-lead member on a completed cycle project +# Points awarded per week for contributing completed issues to a completed cycle project CYCLE_PROJECT_MEMBER_POINTS_PER_WEEK = 15 diff --git a/jobs.py b/jobs.py index fcd0002..80ad4b3 100644 --- a/jobs.py +++ b/jobs.py @@ -511,7 +511,7 @@ def normalize_identity(value: str | None) -> str: markdown += ( "_scores - 20pts for urgent, 10pts for high, 5pts for medium, 1pt for low, " "1pt per merged PR, 1pt per PR review, 30pts/week for completed cycle project leads, " - "15pts/week for completed cycle project members_\n\n" + "15pts/week for completed cycle project contributors_\n\n" ) markdown += f"<{os.getenv('APP_URL')}?days={days}|View Bug Board>" post_to_slack(markdown) diff --git a/leaderboard.py b/leaderboard.py index 446b91f..25f3736 100644 --- a/leaderboard.py +++ b/leaderboard.py @@ -6,7 +6,7 @@ CYCLE_PROJECT_LEAD_POINTS_PER_WEEK, CYCLE_PROJECT_MEMBER_POINTS_PER_WEEK, ) -from linear.projects import get_projects +from linear.projects import get_completed_project_issue_assignees, get_projects def _parse_date(value: str | None) -> datetime | None: @@ -71,20 +71,31 @@ def _calculate_cycle_project_points( window_start = max(start_at, timeframe_start) if window_end <= window_start: continue - members = { - member for member in project.get("members", []) or [] if member and member != lead_name - } - for segment_start, segment_end in week_segments: - overlap_start = max(window_start, segment_start) - overlap_end = min(window_end, segment_end) - if overlap_end > overlap_start: - points_by_lead[lead_name] = ( - points_by_lead.get(lead_name, 0) + CYCLE_PROJECT_LEAD_POINTS_PER_WEEK + scoring_segments = [ + (segment_start, segment_end) + for segment_start, segment_end in week_segments + if min(window_end, segment_end) > max(window_start, segment_start) + ] + if not scoring_segments: + continue + project_id = project.get("id") + contributors = ( + { + contributor + for contributor in get_completed_project_issue_assignees(project_id) + if contributor and contributor != lead_name + } + if project_id + else set() + ) + for _ in scoring_segments: + points_by_lead[lead_name] = ( + points_by_lead.get(lead_name, 0) + CYCLE_PROJECT_LEAD_POINTS_PER_WEEK + ) + for contributor in contributors: + points_by_member[contributor] = ( + points_by_member.get(contributor, 0) + CYCLE_PROJECT_MEMBER_POINTS_PER_WEEK ) - for member in members: - points_by_member[member] = ( - points_by_member.get(member, 0) + CYCLE_PROJECT_MEMBER_POINTS_PER_WEEK - ) return points_by_lead, points_by_member diff --git a/linear/projects.py b/linear/projects.py index 2a64621..df2b107 100644 --- a/linear/projects.py +++ b/linear/projects.py @@ -5,6 +5,52 @@ from .client import _execute +def get_completed_project_issue_assignees(project_id: str) -> list[str]: + """Return sorted unique assignee display names for a project's completed issues.""" + query = gql( + """ + query CompletedProjectIssueAssignees($project_id: String!, $after: String) { + issues( + first: 50 + after: $after + filter: { + project: { id: { eq: $project_id } } + state: { type: { in: ["completed"] } } + } + ) { + nodes { + assignee { + displayName + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + """ + ) + + assignees: set[str] = set() + after = None + while True: + data = _execute(query, variable_values={"project_id": project_id, "after": after}) + issue_connection = data.get("issues", {}) or {} + for issue in issue_connection.get("nodes", []) or []: + assignee = issue.get("assignee") or {} + display_name = assignee.get("displayName") + if display_name: + assignees.add(display_name) + page_info = issue_connection.get("pageInfo", {}) or {} + if not page_info.get("hasNextPage"): + break + after = page_info.get("endCursor") + if not after: + break + return sorted(assignees) + + def _normalize_project_members(projects: list[dict]) -> list[dict]: for project in projects: nodes = project.get("members", {}).get("nodes", []) @@ -58,7 +104,7 @@ def get_projects(): } """ ) - projects = [] + projects: list[dict] = [] after = None while True: data = _execute(query, variable_values={"team_key": team_key, "after": after}) diff --git a/tests/test_leaderboard.py b/tests/test_leaderboard.py index 003470c..c3eb135 100644 --- a/tests/test_leaderboard.py +++ b/tests/test_leaderboard.py @@ -15,7 +15,13 @@ def _import_leaderboard_with_stub(): def _get_projects(): return [] + def _get_completed_project_issue_assignees(_project_id): + return [] + linear_projects_module.get_projects = _get_projects + linear_projects_module.get_completed_project_issue_assignees = ( + _get_completed_project_issue_assignees + ) original_leaderboard = sys.modules.pop("leaderboard", None) try: @@ -37,21 +43,28 @@ def test_released_project_counts_toward_leaderboard(self): leaderboard_module = _import_leaderboard_with_stub() projects = [ { + "id": "project-1", "name": "Google Pay", "status": {"name": "Released", "type": "completed"}, "completedAt": "2026-04-03T00:00:00.000Z", "startDate": "2026-02-09", "targetDate": "2026-03-30", "lead": {"displayName": "nick"}, - "members": ["Austin"], + "members": ["Austin", "Member Only"], } ] now = datetime(2026, 4, 7, tzinfo=timezone.utc) with patch.object(leaderboard_module, "get_projects", return_value=projects): - lead_points = leaderboard_module.calculate_cycle_project_lead_points(30, now) - member_points = leaderboard_module.calculate_cycle_project_member_points(30, now) + with patch.object( + leaderboard_module, + "get_completed_project_issue_assignees", + return_value=["Austin"], + ) as assignees_mock: + lead_points = leaderboard_module.calculate_cycle_project_lead_points(30, now) + member_points = leaderboard_module.calculate_cycle_project_member_points(30, now) + assignees_mock.assert_called_with("project-1") self.assertEqual(lead_points, {"nick": 120}) self.assertEqual(member_points, {"Austin": 60}) @@ -59,6 +72,7 @@ def test_canceled_project_does_not_count_even_with_completed_at(self): leaderboard_module = _import_leaderboard_with_stub() projects = [ { + "id": "project-1", "name": "Canceled Project", "status": {"name": "Canceled", "type": "canceled"}, "completedAt": "2026-04-03T00:00:00.000Z", @@ -76,3 +90,58 @@ def test_canceled_project_does_not_count_even_with_completed_at(self): self.assertEqual(lead_points, {}) self.assertEqual(member_points, {}) + + def test_project_members_without_completed_issues_do_not_get_points(self): + leaderboard_module = _import_leaderboard_with_stub() + projects = [ + { + "id": "project-1", + "name": "Member Only Project", + "status": {"name": "Released", "type": "completed"}, + "completedAt": "2026-04-03T00:00:00.000Z", + "startDate": "2026-03-24", + "targetDate": "2026-03-30", + "lead": {"displayName": "nick"}, + "members": ["Member Only"], + } + ] + now = datetime(2026, 4, 7, tzinfo=timezone.utc) + + with patch.object(leaderboard_module, "get_projects", return_value=projects): + with patch.object( + leaderboard_module, + "get_completed_project_issue_assignees", + return_value=["Contributor"], + ): + member_points = leaderboard_module.calculate_cycle_project_member_points(30, now) + + self.assertEqual(member_points, {"Contributor": 15}) + + def test_assignees_not_fetched_for_projects_outside_window(self): + leaderboard_module = _import_leaderboard_with_stub() + projects = [ + { + "id": "old-project", + "name": "Old Released Project", + "status": {"name": "Released", "type": "completed"}, + "completedAt": "2025-01-01T00:00:00.000Z", + "startDate": "2024-12-01", + "targetDate": "2024-12-15", + "lead": {"displayName": "nick"}, + "members": ["Austin"], + } + ] + now = datetime(2026, 4, 7, tzinfo=timezone.utc) + + with patch.object(leaderboard_module, "get_projects", return_value=projects): + with patch.object( + leaderboard_module, + "get_completed_project_issue_assignees", + return_value=["Austin"], + ) as assignees_mock: + lead_points = leaderboard_module.calculate_cycle_project_lead_points(30, now) + member_points = leaderboard_module.calculate_cycle_project_member_points(30, now) + + assignees_mock.assert_not_called() + self.assertEqual(lead_points, {}) + self.assertEqual(member_points, {}) diff --git a/tests/test_linear_projects.py b/tests/test_linear_projects.py index b4aef91..45d922e 100644 --- a/tests/test_linear_projects.py +++ b/tests/test_linear_projects.py @@ -18,7 +18,9 @@ def test_get_projects_paginates_and_normalizes_members(self): }, "nodes": [ { + "id": "project-1", "name": "Web Giving", + "status": {"type": "started"}, "members": {"nodes": [{"displayName": "Nathan Lewis"}]}, } ], @@ -38,7 +40,9 @@ def test_get_projects_paginates_and_normalizes_members(self): }, "nodes": [ { + "id": "project-2", "name": "Giving History + Recurring Management", + "status": {"type": "completed"}, "members": {"nodes": [{"displayName": "Austin Witherow"}]}, } ], @@ -73,6 +77,51 @@ def fake_execute(_query, variable_values=None): ], ) self.assertEqual(projects[0]["members"], ["Austin Witherow"]) + self.assertEqual(projects[1]["members"], ["Nathan Lewis"]) + + +class GetCompletedProjectIssueAssigneesTest(unittest.TestCase): + def test_paginates_and_returns_sorted_unique_assignees(self): + responses = [ + { + "issues": { + "pageInfo": { + "hasNextPage": True, + "endCursor": "issue-cursor-1", + }, + "nodes": [ + {"assignee": {"displayName": "Austin Witherow"}}, + {"assignee": None}, + ], + } + }, + { + "issues": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [ + {"assignee": {"displayName": "Later Page Contributor"}}, + {"assignee": {"displayName": "Austin Witherow"}}, + ], + } + }, + ] + calls = [] + + def fake_execute(_query, variable_values=None): + calls.append(variable_values) + return responses[len(calls) - 1] + + with patch.object(project_module, "_execute", side_effect=fake_execute): + assignees = project_module.get_completed_project_issue_assignees("project-2") + + self.assertEqual( + calls, + [ + {"project_id": "project-2", "after": None}, + {"project_id": "project-2", "after": "issue-cursor-1"}, + ], + ) + self.assertEqual(assignees, ["Austin Witherow", "Later Page Contributor"]) if __name__ == "__main__":