Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 25 additions & 14 deletions leaderboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip assignee pagination for lead-only scoring

When calculate_cycle_project_lead_points() is called, this shared helper still paginates completed issue assignees even though the returned lead score does not use them. I checked app.py:_build_leaderboard_entries and jobs.py:post_leaderboard; both call the lead wrapper and then the member wrapper, so every in-window completed project now performs the Linear issues pagination twice, and the lead-only call can fail or time out because of an unrelated assignee fetch. Consider only fetching contributors for member scoring or returning both maps from one public call and reusing the result.

Useful? React with 👍 / 👎.

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


Expand Down
48 changes: 47 additions & 1 deletion linear/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [])
Expand Down Expand Up @@ -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})
Expand Down
75 changes: 72 additions & 3 deletions tests/test_leaderboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -37,28 +43,36 @@ 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})

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",
Expand All @@ -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, {})
49 changes: 49 additions & 0 deletions tests/test_linear_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]},
}
],
Expand All @@ -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"}]},
}
],
Expand Down Expand Up @@ -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__":
Expand Down
Loading