diff --git a/jobs.py b/jobs.py index 60170b8..a376f4d 100644 --- a/jobs.py +++ b/jobs.py @@ -28,6 +28,7 @@ get_completed_issues, get_completed_issues_for_person, get_open_issues, + get_open_stale_issues, get_stale_issues_by_assignee, ) from linear.projects import get_projects @@ -556,9 +557,7 @@ def post_stale(): } prs = get_prs_waiting_for_review_by_reviewer() stale_issues = get_stale_issues_by_assignee( - get_open_issues(5, "Bug") - + get_open_issues(5, "Feature Request") - + get_open_issues(5, "Technical Change"), + get_open_stale_issues(), 7, ) if not prs and not stale_issues: diff --git a/linear/issues.py b/linear/issues.py index 52d13bb..3ed35b2 100644 --- a/linear/issues.py +++ b/linear/issues.py @@ -70,6 +70,67 @@ def get_open_issues(priority, label): return issues +def get_open_stale_issues(): + """Return open, non-project issues eligible for stale Slack reminders.""" + team_key = get_linear_team_key() + query = gql( + """ + query OpenStaleIssues($team_key: String!, $cursor: String) { + issues( + first: 50 + after: $cursor + filter: { + team: { key: { eq: $team_key } } + state: { type: { in: ["triage", "backlog", "unstarted", "started"] } } + project: { null: true } + } + orderBy: updatedAt + ) { + nodes { + id + title + assignee { + displayName + name + } + url + labels { + nodes { + name + } + } + createdAt + updatedAt + priority + } + pageInfo { + hasNextPage + endCursor + } + } + } + """, + ) + + cursor = None + issues = [] + while True: + data = _execute(query, variable_values={"team_key": team_key, "cursor": cursor}) + issues += data["issues"]["nodes"] + if not data["issues"]["pageInfo"]["hasNextPage"]: + break + cursor = data["issues"]["pageInfo"]["endCursor"] + + for issue in issues: + platforms = [ + tag["name"] + for tag in issue.get("labels", {}).get("nodes", []) + if tag["name"].lower().replace(" ", "-") in get_platforms() + ] + issue["platform"] = platforms[0] if platforms else None + return issues + + def get_completed_issues(priority, label, days=30): team_key = get_linear_team_key() query = gql( diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 193a4ee..d5f50e3 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -62,6 +62,7 @@ def _install_import_shims() -> None: linear_issues_module.get_completed_issues = lambda *args, **kwargs: [] linear_issues_module.get_completed_issues_for_person = lambda *args, **kwargs: [] linear_issues_module.get_open_issues = lambda *args, **kwargs: [] + linear_issues_module.get_open_stale_issues = lambda *args, **kwargs: [] linear_issues_module.get_open_issues_in_projects = lambda *args, **kwargs: [] linear_issues_module.get_stale_issues_by_assignee = lambda *args, **kwargs: {} sys.modules.setdefault("linear.issues", linear_issues_module) @@ -219,6 +220,61 @@ def test_runs_leaderboard_stale_and_project_updates(self): project_updates.assert_called_once_with() +class PostStaleTest(unittest.TestCase): + def test_uses_open_stale_issues_without_label_or_priority_queries(self): + open_issues = [{"id": "APO-7555"}] + + def fake_get_stale_issues(issues, days): + self.assertIs(issues, open_issues) + self.assertEqual(days, 7) + return { + "dylan": [ + { + "title": "Regression in Apple Pay campus/fund confirmation flow", + "url": "https://linear.app/differential/issue/APO-7555", + "daysStale": 74, + "priority": 0, + "platform": None, + } + ] + } + + with patch.object( + jobs_module, + "get_team_members", + return_value={"dylan": {"linear_username": "dylan", "slack_id": "U03LD9MJLNP"}}, + ): + with patch.object( + jobs_module, "get_prs_waiting_for_review_by_reviewer", return_value={} + ): + with patch.object( + jobs_module, + "get_open_issues", + side_effect=AssertionError("post_stale should use get_open_stale_issues"), + ): + with patch.object( + jobs_module, "get_open_stale_issues", return_value=open_issues + ): + with patch.object( + jobs_module, + "get_stale_issues_by_assignee", + side_effect=fake_get_stale_issues, + ): + with patch.dict( + jobs_module.os.environ, + {"APP_URL": "https://bug-board.example"}, + clear=False, + ): + with patch.object(jobs_module, "post_to_slack") as post: + jobs_module.post_stale() + + post.assert_called_once() + message = post.call_args.args[0] + self.assertIn("*Stale Open Issues*", message) + self.assertIn("APO-7555", message) + self.assertIn("(74d)", message) + + class AirflowFleetHeartbeatTest(unittest.TestCase): def setUp(self): jobs_module._airflow_fleet_unknown_heartbeat_failures = 0 diff --git a/tests/test_linear_issue_state_filters.py b/tests/test_linear_issue_state_filters.py index 8ae2669..11b34a9 100644 --- a/tests/test_linear_issue_state_filters.py +++ b/tests/test_linear_issue_state_filters.py @@ -115,6 +115,44 @@ def fake_execute(query, variable_values=None): self.assertEqual(issues[0]["daysOpen"], 1) self.assertEqual(issues[0]["slaText"], "5h overdue") + def test_get_open_stale_issues_has_no_label_or_priority_constraint(self): + execute_calls = [] + response = { + "issues": { + "nodes": [ + { + "title": "Unprioritized stale issue", + "createdAt": "2026-03-01T00:00:00.000Z", + "updatedAt": "2026-03-01T12:00:00.000Z", + "labels": {"nodes": []}, + "priority": 0, + } + ], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + + def fake_execute(query, variable_values=None): + execute_calls.append((query, variable_values)) + return response + + with patch.object(issues_module, "_execute", side_effect=fake_execute): + with patch.object(issues_module, "get_linear_team_key", return_value="APO"): + with patch.object(issues_module, "get_platforms", return_value={"mobile"}): + issues = issues_module.get_open_stale_issues() + + query_text = _compact_query_text(execute_calls[0][0]) + self.assertIn( + 'state:{type:{in:["triage","backlog","unstarted","started"]}}', + query_text, + ) + self.assertIn("project:{null:true}", query_text) + self.assertNotIn("labels:{name:{eq:$label}}", query_text) + self.assertNotIn("priority:{", query_text) + self.assertEqual(execute_calls[0][1], {"team_key": "APO", "cursor": None}) + self.assertEqual(issues[0]["priority"], 0) + self.assertIsNone(issues[0]["platform"]) + def test_get_completed_issues_summary_uses_completed_state_type(self): execute_calls = [] response = {