From a912d3023af1aa551721a76da53339aaf7cea8fa Mon Sep 17 00:00:00 2001 From: Michael Neeley Date: Thu, 7 May 2026 20:45:13 -0400 Subject: [PATCH 1/4] Score completed project contributors --- app.py | 2 +- constants.py | 2 +- jobs.py | 2 +- leaderboard.py | 12 +++++++----- linear/projects.py | 23 +++++++++++++++++++---- tests/test_leaderboard.py | 25 ++++++++++++++++++++++++- tests/test_linear_projects.py | 13 +++++++++++++ 7 files changed, 66 insertions(+), 13 deletions(-) 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..d4b0157 100644 --- a/leaderboard.py +++ b/leaderboard.py @@ -71,8 +71,10 @@ 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 + contributors = { + contributor + for contributor in project.get("completedIssueAssignees", []) or [] + if contributor and contributor != lead_name } for segment_start, segment_end in week_segments: overlap_start = max(window_start, segment_start) @@ -81,9 +83,9 @@ def _calculate_cycle_project_points( points_by_lead[lead_name] = ( points_by_lead.get(lead_name, 0) + CYCLE_PROJECT_LEAD_POINTS_PER_WEEK ) - for member in members: - points_by_member[member] = ( - points_by_member.get(member, 0) + CYCLE_PROJECT_MEMBER_POINTS_PER_WEEK + for contributor in contributors: + points_by_member[contributor] = ( + points_by_member.get(contributor, 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..69cdd1e 100644 --- a/linear/projects.py +++ b/linear/projects.py @@ -5,10 +5,18 @@ from .client import _execute -def _normalize_project_members(projects: list[dict]) -> list[dict]: +def _normalize_project_participants(projects: list[dict]) -> list[dict]: for project in projects: - nodes = project.get("members", {}).get("nodes", []) - project["members"] = [m["displayName"] for m in nodes if m.get("displayName")] + member_nodes = project.get("members", {}).get("nodes", []) + project["members"] = [m["displayName"] for m in member_nodes if m.get("displayName")] + + issue_nodes = project.get("issues", {}).get("nodes", []) + completed_issue_assignees = { + issue["assignee"]["displayName"] + for issue in issue_nodes + if issue.get("assignee") and issue["assignee"].get("displayName") + } + project["completedIssueAssignees"] = sorted(completed_issue_assignees) return projects @@ -51,6 +59,13 @@ def get_projects(): displayName } } + issues(first: 50, filter: { state: { type: { in: ["completed"] } } }) { + nodes { + assignee { + displayName + } + } + } } } } @@ -74,4 +89,4 @@ def get_projects(): if not after: break sorted_projects = sorted(projects, key=lambda project: project.get("name", "")) - return _normalize_project_members(sorted_projects) + return _normalize_project_participants(sorted_projects) diff --git a/tests/test_leaderboard.py b/tests/test_leaderboard.py index 003470c..4b966c6 100644 --- a/tests/test_leaderboard.py +++ b/tests/test_leaderboard.py @@ -43,7 +43,8 @@ def test_released_project_counts_toward_leaderboard(self): "startDate": "2026-02-09", "targetDate": "2026-03-30", "lead": {"displayName": "nick"}, - "members": ["Austin"], + "members": ["Austin", "Member Only"], + "completedIssueAssignees": ["Austin"], } ] now = datetime(2026, 4, 7, tzinfo=timezone.utc) @@ -66,6 +67,7 @@ def test_canceled_project_does_not_count_even_with_completed_at(self): "targetDate": "2026-03-30", "lead": {"displayName": "nick"}, "members": ["Austin"], + "completedIssueAssignees": ["Austin"], } ] now = datetime(2026, 4, 7, tzinfo=timezone.utc) @@ -76,3 +78,24 @@ 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 = [ + { + "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"], + "completedIssueAssignees": ["Contributor"], + } + ] + now = datetime(2026, 4, 7, tzinfo=timezone.utc) + + with patch.object(leaderboard_module, "get_projects", return_value=projects): + member_points = leaderboard_module.calculate_cycle_project_member_points(30, now) + + self.assertEqual(member_points, {"Contributor": 15}) diff --git a/tests/test_linear_projects.py b/tests/test_linear_projects.py index b4aef91..e4e5586 100644 --- a/tests/test_linear_projects.py +++ b/tests/test_linear_projects.py @@ -20,6 +20,12 @@ def test_get_projects_paginates_and_normalizes_members(self): { "name": "Web Giving", "members": {"nodes": [{"displayName": "Nathan Lewis"}]}, + "issues": { + "nodes": [ + {"assignee": {"displayName": "Nathan Lewis"}}, + {"assignee": None}, + ] + }, } ], } @@ -40,6 +46,12 @@ def test_get_projects_paginates_and_normalizes_members(self): { "name": "Giving History + Recurring Management", "members": {"nodes": [{"displayName": "Austin Witherow"}]}, + "issues": { + "nodes": [ + {"assignee": {"displayName": "Austin Witherow"}}, + {"assignee": {"displayName": "Austin Witherow"}}, + ] + }, } ], } @@ -73,6 +85,7 @@ def fake_execute(_query, variable_values=None): ], ) self.assertEqual(projects[0]["members"], ["Austin Witherow"]) + self.assertEqual(projects[0]["completedIssueAssignees"], ["Austin Witherow"]) if __name__ == "__main__": From 6d1a829914bb8d4e06f3f291f13b511adfd7d94b Mon Sep 17 00:00:00 2001 From: Michael Neeley Date: Thu, 7 May 2026 20:53:07 -0400 Subject: [PATCH 2/4] Paginate completed project issue assignees --- linear/projects.py | 71 ++++++++++++++++++++++++++++------- tests/test_linear_projects.py | 58 +++++++++++++++++++++------- 2 files changed, 102 insertions(+), 27 deletions(-) diff --git a/linear/projects.py b/linear/projects.py index 69cdd1e..5baec24 100644 --- a/linear/projects.py +++ b/linear/projects.py @@ -5,18 +5,68 @@ from .client import _execute +def _completed_issue_assignee_names(issue_nodes: list[dict]) -> list[str]: + completed_issue_assignees = { + issue["assignee"]["displayName"] + for issue in issue_nodes + if issue.get("assignee") and issue["assignee"].get("displayName") + } + return sorted(completed_issue_assignees) + + +def _get_completed_project_issue_assignees(project_id: str) -> list[str]: + 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 + } + } + } + """ + ) + + issue_nodes: list[dict] = [] + after = None + while True: + data = _execute(query, variable_values={"project_id": project_id, "after": after}) + issue_connection = data.get("issues", {}) or {} + issue_nodes.extend(issue_connection.get("nodes", []) or []) + page_info = issue_connection.get("pageInfo", {}) or {} + if not page_info.get("hasNextPage"): + break + after = page_info.get("endCursor") + if not after: + break + return _completed_issue_assignee_names(issue_nodes) + + def _normalize_project_participants(projects: list[dict]) -> list[dict]: for project in projects: member_nodes = project.get("members", {}).get("nodes", []) project["members"] = [m["displayName"] for m in member_nodes if m.get("displayName")] - issue_nodes = project.get("issues", {}).get("nodes", []) - completed_issue_assignees = { - issue["assignee"]["displayName"] - for issue in issue_nodes - if issue.get("assignee") and issue["assignee"].get("displayName") - } - project["completedIssueAssignees"] = sorted(completed_issue_assignees) + project_id = project.get("id") + if project_id: + project["completedIssueAssignees"] = _get_completed_project_issue_assignees(project_id) + else: + issue_nodes = project.get("issues", {}).get("nodes", []) + project["completedIssueAssignees"] = _completed_issue_assignee_names(issue_nodes) + project.pop("issues", None) return projects @@ -59,13 +109,6 @@ def get_projects(): displayName } } - issues(first: 50, filter: { state: { type: { in: ["completed"] } } }) { - nodes { - assignee { - displayName - } - } - } } } } diff --git a/tests/test_linear_projects.py b/tests/test_linear_projects.py index e4e5586..b0b42de 100644 --- a/tests/test_linear_projects.py +++ b/tests/test_linear_projects.py @@ -18,14 +18,9 @@ def test_get_projects_paginates_and_normalizes_members(self): }, "nodes": [ { + "id": "project-1", "name": "Web Giving", "members": {"nodes": [{"displayName": "Nathan Lewis"}]}, - "issues": { - "nodes": [ - {"assignee": {"displayName": "Nathan Lewis"}}, - {"assignee": None}, - ] - }, } ], } @@ -44,14 +39,9 @@ def test_get_projects_paginates_and_normalizes_members(self): }, "nodes": [ { + "id": "project-2", "name": "Giving History + Recurring Management", "members": {"nodes": [{"displayName": "Austin Witherow"}]}, - "issues": { - "nodes": [ - {"assignee": {"displayName": "Austin Witherow"}}, - {"assignee": {"displayName": "Austin Witherow"}}, - ] - }, } ], } @@ -59,6 +49,41 @@ def test_get_projects_paginates_and_normalizes_members(self): ] } }, + { + "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"}}, + ], + } + }, + { + "issues": { + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + "nodes": [ + {"assignee": {"displayName": "Nathan Lewis"}}, + ], + } + }, ] calls = [] @@ -75,6 +100,9 @@ def fake_execute(_query, variable_values=None): [ {"team_key": "APO", "after": None}, {"team_key": "APO", "after": "cursor-1"}, + {"project_id": "project-2", "after": None}, + {"project_id": "project-2", "after": "issue-cursor-1"}, + {"project_id": "project-1", "after": None}, ], ) self.assertEqual( @@ -85,7 +113,11 @@ def fake_execute(_query, variable_values=None): ], ) self.assertEqual(projects[0]["members"], ["Austin Witherow"]) - self.assertEqual(projects[0]["completedIssueAssignees"], ["Austin Witherow"]) + self.assertEqual( + projects[0]["completedIssueAssignees"], + ["Austin Witherow", "Later Page Contributor"], + ) + self.assertEqual(projects[1]["completedIssueAssignees"], ["Nathan Lewis"]) if __name__ == "__main__": From 00ddbb446f3c65452664da7e8b2d63b458066e18 Mon Sep 17 00:00:00 2001 From: Michael Neeley Date: Thu, 7 May 2026 21:30:33 -0400 Subject: [PATCH 3/4] Make completed project assignee fetch opt-in --- leaderboard.py | 2 +- linear/projects.py | 33 +++++--- tests/test_leaderboard.py | 14 +++- tests/test_linear_projects.py | 149 +++++++++++++++++++--------------- 4 files changed, 119 insertions(+), 79 deletions(-) diff --git a/leaderboard.py b/leaderboard.py index d4b0157..87be180 100644 --- a/leaderboard.py +++ b/leaderboard.py @@ -48,7 +48,7 @@ def _calculate_cycle_project_points( if days <= 0: return {}, {} now = now or datetime.now(timezone.utc) - projects = get_projects() + projects = get_projects(include_completed_issue_assignees=True) timeframe_start = now - timedelta(days=days) week_segments = _build_week_segments(timeframe_start, now) points_by_lead: dict[str, int] = {} diff --git a/linear/projects.py b/linear/projects.py index 5baec24..937fe76 100644 --- a/linear/projects.py +++ b/linear/projects.py @@ -55,22 +55,32 @@ def _get_completed_project_issue_assignees(project_id: str) -> list[str]: return _completed_issue_assignee_names(issue_nodes) -def _normalize_project_participants(projects: list[dict]) -> list[dict]: +def _is_completed_project(project: dict) -> bool: + status_type = ((project.get("status") or {}).get("type") or "").strip().lower() + return status_type == "completed" + + +def _normalize_project_participants( + projects: list[dict], *, include_completed_issue_assignees: bool = False +) -> list[dict]: for project in projects: member_nodes = project.get("members", {}).get("nodes", []) project["members"] = [m["displayName"] for m in member_nodes if m.get("displayName")] - project_id = project.get("id") - if project_id: - project["completedIssueAssignees"] = _get_completed_project_issue_assignees(project_id) - else: - issue_nodes = project.get("issues", {}).get("nodes", []) - project["completedIssueAssignees"] = _completed_issue_assignee_names(issue_nodes) + if include_completed_issue_assignees: + project_id = project.get("id") + if project_id and _is_completed_project(project): + project["completedIssueAssignees"] = _get_completed_project_issue_assignees( + project_id + ) + else: + issue_nodes = project.get("issues", {}).get("nodes", []) + project["completedIssueAssignees"] = _completed_issue_assignee_names(issue_nodes) project.pop("issues", None) return projects -def get_projects(): +def get_projects(*, include_completed_issue_assignees: bool = False): """Return all Linear projects under the Apollos team, ordered by name.""" team_key = get_linear_team_key() query = gql( @@ -116,7 +126,7 @@ def get_projects(): } """ ) - projects = [] + projects: list[dict] = [] after = None while True: data = _execute(query, variable_values={"team_key": team_key, "after": after}) @@ -132,4 +142,7 @@ def get_projects(): if not after: break sorted_projects = sorted(projects, key=lambda project: project.get("name", "")) - return _normalize_project_participants(sorted_projects) + return _normalize_project_participants( + sorted_projects, + include_completed_issue_assignees=include_completed_issue_assignees, + ) diff --git a/tests/test_leaderboard.py b/tests/test_leaderboard.py index 4b966c6..29d60b7 100644 --- a/tests/test_leaderboard.py +++ b/tests/test_leaderboard.py @@ -3,7 +3,7 @@ import types from datetime import datetime, timezone from unittest import TestCase -from unittest.mock import patch +from unittest.mock import call, patch def _import_leaderboard_with_stub(): @@ -12,7 +12,7 @@ def _import_leaderboard_with_stub(): linear_projects_module = types.ModuleType("linear.projects") - def _get_projects(): + def _get_projects(*, include_completed_issue_assignees=False): return [] linear_projects_module.get_projects = _get_projects @@ -49,10 +49,18 @@ def test_released_project_counts_toward_leaderboard(self): ] now = datetime(2026, 4, 7, tzinfo=timezone.utc) - with patch.object(leaderboard_module, "get_projects", return_value=projects): + with patch.object( + leaderboard_module, "get_projects", return_value=projects + ) as get_projects_mock: lead_points = leaderboard_module.calculate_cycle_project_lead_points(30, now) member_points = leaderboard_module.calculate_cycle_project_member_points(30, now) + get_projects_mock.assert_has_calls( + [ + call(include_completed_issue_assignees=True), + call(include_completed_issue_assignees=True), + ] + ) self.assertEqual(lead_points, {"nick": 120}) self.assertEqual(member_points, {"Austin": 60}) diff --git a/tests/test_linear_projects.py b/tests/test_linear_projects.py index b0b42de..ebd9b6f 100644 --- a/tests/test_linear_projects.py +++ b/tests/test_linear_projects.py @@ -4,51 +4,90 @@ from linear import projects as project_module -class GetProjectsTest(unittest.TestCase): - def test_get_projects_paginates_and_normalizes_members(self): - responses = [ - { - "teams": { - "nodes": [ - { - "projects": { - "pageInfo": { - "hasNextPage": True, - "endCursor": "cursor-1", - }, - "nodes": [ - { - "id": "project-1", - "name": "Web Giving", - "members": {"nodes": [{"displayName": "Nathan Lewis"}]}, - } - ], - } +def _project_pages(): + return [ + { + "teams": { + "nodes": [ + { + "projects": { + "pageInfo": { + "hasNextPage": True, + "endCursor": "cursor-1", + }, + "nodes": [ + { + "id": "project-1", + "name": "Web Giving", + "status": {"type": "started"}, + "members": {"nodes": [{"displayName": "Nathan Lewis"}]}, + } + ], } - ] - } - }, - { - "teams": { - "nodes": [ - { - "projects": { - "pageInfo": { - "hasNextPage": False, - "endCursor": None, - }, - "nodes": [ - { - "id": "project-2", - "name": "Giving History + Recurring Management", - "members": {"nodes": [{"displayName": "Austin Witherow"}]}, - } - ], - } + } + ] + } + }, + { + "teams": { + "nodes": [ + { + "projects": { + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + "nodes": [ + { + "id": "project-2", + "name": "Giving History + Recurring Management", + "status": {"type": "completed"}, + "members": {"nodes": [{"displayName": "Austin Witherow"}]}, + } + ], } - ] - } - }, + } + ] + } + }, + ] + + +class GetProjectsTest(unittest.TestCase): + def test_get_projects_paginates_and_normalizes_members_without_assignee_fetches(self): + responses = _project_pages() + 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): + with patch.object(project_module, "get_linear_team_key", return_value="APO"): + projects = project_module.get_projects() + + self.assertEqual( + calls, + [ + {"team_key": "APO", "after": None}, + {"team_key": "APO", "after": "cursor-1"}, + ], + ) + self.assertEqual( + [project["name"] for project in projects], + [ + "Giving History + Recurring Management", + "Web Giving", + ], + ) + self.assertEqual(projects[0]["members"], ["Austin Witherow"]) + self.assertNotIn("completedIssueAssignees", projects[0]) + self.assertEqual(projects[1]["members"], ["Nathan Lewis"]) + self.assertNotIn("completedIssueAssignees", projects[1]) + + def test_get_projects_can_fetch_completed_issue_assignees_for_completed_projects(self): + responses = [ + *_project_pages(), { "issues": { "pageInfo": { @@ -73,17 +112,6 @@ def test_get_projects_paginates_and_normalizes_members(self): ], } }, - { - "issues": { - "pageInfo": { - "hasNextPage": False, - "endCursor": None, - }, - "nodes": [ - {"assignee": {"displayName": "Nathan Lewis"}}, - ], - } - }, ] calls = [] @@ -93,7 +121,7 @@ def fake_execute(_query, variable_values=None): with patch.object(project_module, "_execute", side_effect=fake_execute): with patch.object(project_module, "get_linear_team_key", return_value="APO"): - projects = project_module.get_projects() + projects = project_module.get_projects(include_completed_issue_assignees=True) self.assertEqual( calls, @@ -102,22 +130,13 @@ def fake_execute(_query, variable_values=None): {"team_key": "APO", "after": "cursor-1"}, {"project_id": "project-2", "after": None}, {"project_id": "project-2", "after": "issue-cursor-1"}, - {"project_id": "project-1", "after": None}, - ], - ) - self.assertEqual( - [project["name"] for project in projects], - [ - "Giving History + Recurring Management", - "Web Giving", ], ) - self.assertEqual(projects[0]["members"], ["Austin Witherow"]) self.assertEqual( projects[0]["completedIssueAssignees"], ["Austin Witherow", "Later Page Contributor"], ) - self.assertEqual(projects[1]["completedIssueAssignees"], ["Nathan Lewis"]) + self.assertEqual(projects[1]["completedIssueAssignees"], []) if __name__ == "__main__": From 483a9e41e4d6b0259b5866551e667c8b620e8d78 Mon Sep 17 00:00:00 2001 From: redreceipt Date: Thu, 7 May 2026 23:17:33 -0400 Subject: [PATCH 4/4] Fetch completed-project assignees only for scoring projects Previously get_projects(include_completed_issue_assignees=True) made an extra paginated Linear issues query for every completed project, even ones whose target window fell entirely outside the leaderboard's days range. Move the assignee fetch into leaderboard.py so it only runs for projects that will actually score, eliminating the wasted N+1 on stale projects. Drop the now-unused parameter and the dead preloaded-issues fallback in linear/projects.py. --- leaderboard.py | 43 +++++++----- linear/projects.py | 53 ++++----------- tests/test_leaderboard.py | 74 ++++++++++++++++----- tests/test_linear_projects.py | 119 +++++++++++++++------------------- 4 files changed, 148 insertions(+), 141 deletions(-) diff --git a/leaderboard.py b/leaderboard.py index 87be180..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: @@ -48,7 +48,7 @@ def _calculate_cycle_project_points( if days <= 0: return {}, {} now = now or datetime.now(timezone.utc) - projects = get_projects(include_completed_issue_assignees=True) + projects = get_projects() timeframe_start = now - timedelta(days=days) week_segments = _build_week_segments(timeframe_start, now) points_by_lead: dict[str, int] = {} @@ -71,22 +71,31 @@ def _calculate_cycle_project_points( window_start = max(start_at, timeframe_start) if window_end <= window_start: continue - contributors = { - contributor - for contributor in project.get("completedIssueAssignees", []) or [] - if contributor and contributor != 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 contributor in contributors: - points_by_member[contributor] = ( - points_by_member.get(contributor, 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 937fe76..df2b107 100644 --- a/linear/projects.py +++ b/linear/projects.py @@ -5,16 +5,8 @@ from .client import _execute -def _completed_issue_assignee_names(issue_nodes: list[dict]) -> list[str]: - completed_issue_assignees = { - issue["assignee"]["displayName"] - for issue in issue_nodes - if issue.get("assignee") and issue["assignee"].get("displayName") - } - return sorted(completed_issue_assignees) - - -def _get_completed_project_issue_assignees(project_id: str) -> list[str]: +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) { @@ -40,47 +32,33 @@ def _get_completed_project_issue_assignees(project_id: str) -> list[str]: """ ) - issue_nodes: list[dict] = [] + 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 {} - issue_nodes.extend(issue_connection.get("nodes", []) 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 _completed_issue_assignee_names(issue_nodes) - + return sorted(assignees) -def _is_completed_project(project: dict) -> bool: - status_type = ((project.get("status") or {}).get("type") or "").strip().lower() - return status_type == "completed" - -def _normalize_project_participants( - projects: list[dict], *, include_completed_issue_assignees: bool = False -) -> list[dict]: +def _normalize_project_members(projects: list[dict]) -> list[dict]: for project in projects: - member_nodes = project.get("members", {}).get("nodes", []) - project["members"] = [m["displayName"] for m in member_nodes if m.get("displayName")] - - if include_completed_issue_assignees: - project_id = project.get("id") - if project_id and _is_completed_project(project): - project["completedIssueAssignees"] = _get_completed_project_issue_assignees( - project_id - ) - else: - issue_nodes = project.get("issues", {}).get("nodes", []) - project["completedIssueAssignees"] = _completed_issue_assignee_names(issue_nodes) - project.pop("issues", None) + nodes = project.get("members", {}).get("nodes", []) + project["members"] = [m["displayName"] for m in nodes if m.get("displayName")] return projects -def get_projects(*, include_completed_issue_assignees: bool = False): +def get_projects(): """Return all Linear projects under the Apollos team, ordered by name.""" team_key = get_linear_team_key() query = gql( @@ -142,7 +120,4 @@ def get_projects(*, include_completed_issue_assignees: bool = False): if not after: break sorted_projects = sorted(projects, key=lambda project: project.get("name", "")) - return _normalize_project_participants( - sorted_projects, - include_completed_issue_assignees=include_completed_issue_assignees, - ) + return _normalize_project_members(sorted_projects) diff --git a/tests/test_leaderboard.py b/tests/test_leaderboard.py index 29d60b7..c3eb135 100644 --- a/tests/test_leaderboard.py +++ b/tests/test_leaderboard.py @@ -3,7 +3,7 @@ import types from datetime import datetime, timezone from unittest import TestCase -from unittest.mock import call, patch +from unittest.mock import patch def _import_leaderboard_with_stub(): @@ -12,10 +12,16 @@ def _import_leaderboard_with_stub(): linear_projects_module = types.ModuleType("linear.projects") - def _get_projects(*, include_completed_issue_assignees=False): + 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,6 +43,7 @@ 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", @@ -44,23 +51,20 @@ def test_released_project_counts_toward_leaderboard(self): "targetDate": "2026-03-30", "lead": {"displayName": "nick"}, "members": ["Austin", "Member Only"], - "completedIssueAssignees": ["Austin"], } ] now = datetime(2026, 4, 7, tzinfo=timezone.utc) - with patch.object( - leaderboard_module, "get_projects", return_value=projects - ) as get_projects_mock: - lead_points = leaderboard_module.calculate_cycle_project_lead_points(30, now) - member_points = leaderboard_module.calculate_cycle_project_member_points(30, now) - - get_projects_mock.assert_has_calls( - [ - call(include_completed_issue_assignees=True), - call(include_completed_issue_assignees=True), - ] - ) + 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_called_with("project-1") self.assertEqual(lead_points, {"nick": 120}) self.assertEqual(member_points, {"Austin": 60}) @@ -68,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", @@ -75,7 +80,6 @@ def test_canceled_project_does_not_count_even_with_completed_at(self): "targetDate": "2026-03-30", "lead": {"displayName": "nick"}, "members": ["Austin"], - "completedIssueAssignees": ["Austin"], } ] now = datetime(2026, 4, 7, tzinfo=timezone.utc) @@ -91,6 +95,7 @@ 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", @@ -98,12 +103,45 @@ def test_project_members_without_completed_issues_do_not_get_points(self): "targetDate": "2026-03-30", "lead": {"displayName": "nick"}, "members": ["Member Only"], - "completedIssueAssignees": ["Contributor"], } ] now = datetime(2026, 4, 7, tzinfo=timezone.utc) with patch.object(leaderboard_module, "get_projects", return_value=projects): - member_points = leaderboard_module.calculate_cycle_project_member_points(30, now) + 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 ebd9b6f..45d922e 100644 --- a/tests/test_linear_projects.py +++ b/tests/test_linear_projects.py @@ -4,58 +4,54 @@ from linear import projects as project_module -def _project_pages(): - return [ - { - "teams": { - "nodes": [ - { - "projects": { - "pageInfo": { - "hasNextPage": True, - "endCursor": "cursor-1", - }, - "nodes": [ - { - "id": "project-1", - "name": "Web Giving", - "status": {"type": "started"}, - "members": {"nodes": [{"displayName": "Nathan Lewis"}]}, - } - ], +class GetProjectsTest(unittest.TestCase): + def test_get_projects_paginates_and_normalizes_members(self): + responses = [ + { + "teams": { + "nodes": [ + { + "projects": { + "pageInfo": { + "hasNextPage": True, + "endCursor": "cursor-1", + }, + "nodes": [ + { + "id": "project-1", + "name": "Web Giving", + "status": {"type": "started"}, + "members": {"nodes": [{"displayName": "Nathan Lewis"}]}, + } + ], + } } - } - ] - } - }, - { - "teams": { - "nodes": [ - { - "projects": { - "pageInfo": { - "hasNextPage": False, - "endCursor": None, - }, - "nodes": [ - { - "id": "project-2", - "name": "Giving History + Recurring Management", - "status": {"type": "completed"}, - "members": {"nodes": [{"displayName": "Austin Witherow"}]}, - } - ], + ] + } + }, + { + "teams": { + "nodes": [ + { + "projects": { + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + "nodes": [ + { + "id": "project-2", + "name": "Giving History + Recurring Management", + "status": {"type": "completed"}, + "members": {"nodes": [{"displayName": "Austin Witherow"}]}, + } + ], + } } - } - ] - } - }, - ] - - -class GetProjectsTest(unittest.TestCase): - def test_get_projects_paginates_and_normalizes_members_without_assignee_fetches(self): - responses = _project_pages() + ] + } + }, + ] calls = [] def fake_execute(_query, variable_values=None): @@ -81,13 +77,12 @@ def fake_execute(_query, variable_values=None): ], ) self.assertEqual(projects[0]["members"], ["Austin Witherow"]) - self.assertNotIn("completedIssueAssignees", projects[0]) self.assertEqual(projects[1]["members"], ["Nathan Lewis"]) - self.assertNotIn("completedIssueAssignees", projects[1]) - def test_get_projects_can_fetch_completed_issue_assignees_for_completed_projects(self): + +class GetCompletedProjectIssueAssigneesTest(unittest.TestCase): + def test_paginates_and_returns_sorted_unique_assignees(self): responses = [ - *_project_pages(), { "issues": { "pageInfo": { @@ -102,10 +97,7 @@ def test_get_projects_can_fetch_completed_issue_assignees_for_completed_projects }, { "issues": { - "pageInfo": { - "hasNextPage": False, - "endCursor": None, - }, + "pageInfo": {"hasNextPage": False, "endCursor": None}, "nodes": [ {"assignee": {"displayName": "Later Page Contributor"}}, {"assignee": {"displayName": "Austin Witherow"}}, @@ -120,23 +112,16 @@ def fake_execute(_query, variable_values=None): return responses[len(calls) - 1] with patch.object(project_module, "_execute", side_effect=fake_execute): - with patch.object(project_module, "get_linear_team_key", return_value="APO"): - projects = project_module.get_projects(include_completed_issue_assignees=True) + assignees = project_module.get_completed_project_issue_assignees("project-2") self.assertEqual( calls, [ - {"team_key": "APO", "after": None}, - {"team_key": "APO", "after": "cursor-1"}, {"project_id": "project-2", "after": None}, {"project_id": "project-2", "after": "issue-cursor-1"}, ], ) - self.assertEqual( - projects[0]["completedIssueAssignees"], - ["Austin Witherow", "Later Page Contributor"], - ) - self.assertEqual(projects[1]["completedIssueAssignees"], []) + self.assertEqual(assignees, ["Austin Witherow", "Later Page Contributor"]) if __name__ == "__main__":