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
5 changes: 2 additions & 3 deletions jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
61 changes: 61 additions & 0 deletions linear/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions tests/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/test_linear_issue_state_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading