Skip to content

Commit 6eaaa9d

Browse files
committed
Add a slack notification for open automated PRs before an iOS merge day
1 parent d308c70 commit 6eaaa9d

4 files changed

Lines changed: 234 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Firefox iOS open PR Slack announcement
2+
3+
on:
4+
schedule:
5+
# Thursdays at 16:00 UTC — the day before we branch for release,
6+
# this gives time for folks to review and merge.
7+
- cron: '0 16 * * 4'
8+
workflow_dispatch:
9+
inputs:
10+
dry_run:
11+
description: 'Print the message instead of posting to Slack'
12+
required: false
13+
type: boolean
14+
default: true
15+
test_date:
16+
description: 'Pretend today is this date (YYYY-MM-DD), e.g. 2026-08-27'
17+
required: false
18+
type: string
19+
20+
jobs:
21+
announce:
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 30
24+
permissions:
25+
contents: read
26+
steps:
27+
- uses: actions/checkout@v5
28+
with:
29+
persist-credentials: false
30+
31+
- uses: actions/setup-python@v5
32+
with:
33+
python-version: '3.12'
34+
35+
- name: Remind if there are any open automated PRs pending before we branch
36+
env:
37+
SLACK_WEBHOOK_URL: ${{ secrets.IOS_DEV_SLACK_WEBHOOK }}
38+
DRY_RUN: ${{ inputs.dry_run }}
39+
TEST_DATE: ${{ inputs.test_date }}
40+
run: python3 app/scripts/ios_open_pr_slack_notifier.py
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Reminds firefox-ios-dev about automated pull requests still open before we branch.
4+
5+
The l10n import and similar housekeeping PRs are opened by GitHub Actions and can sit
6+
unreviewed. Anything still open when we branch misses the release, so this posts a
7+
nudge while there is time to land them.
8+
9+
Run weekly by .github/workflows/ios-open-pr-slack.yml, on Thursday, the day before we
10+
branch. Posts nothing when the queue is empty or when no merge day is imminent, so a
11+
quiet week is silent rather than noisy.
12+
13+
Merge day is normally the Friday. When that Friday is a wellness day the merge moves
14+
back onto the Thursday, so the message says "today" and gives the reason instead of
15+
claiming a branch that is not happening tomorrow.
16+
17+
Requires the SLACK_WEBHOOK_URL environment variable (set from the
18+
IOS_DEV_SLACK_WEBHOOK repo secret in the workflow).
19+
20+
Env vars for testing:
21+
DRY_RUN=1 print the message instead of posting it. Accepts
22+
1/true/yes/on to enable and 0/false/no/off or empty to
23+
disable; anything else is an error rather than a guess.
24+
TEST_DATE=Y-m-d pretend today is this date, to exercise a merge day that
25+
isn't today (e.g. 2026-08-27 for the wellness case)
26+
"""
27+
import os
28+
import sys
29+
from datetime import date, datetime, timedelta
30+
31+
from lib.env import env_flag
32+
from lib.fetch import fetch_json
33+
from lib.schedule import ios_merge_days, today as utc_today
34+
from lib.slack import post_to_slack
35+
36+
REPO = "mozilla-mobile/firefox-ios"
37+
# The GitHub Actions app, which opens the l10n import PRs. "app/" is how GitHub's
38+
# search addresses an app rather than a user; the PRs show up as github-actions[bot].
39+
AUTHOR = "app/github-actions"
40+
41+
SEARCH_URL = (
42+
"https://api.github.com/search/issues"
43+
f"?q=repo:{REPO}+is:pr+is:open+author:{AUTHOR}"
44+
"&sort=created&order=asc&per_page=100"
45+
)
46+
47+
# When the branch happens, which is what makes the ask urgent. Merge day is normally
48+
# the Friday after this Thursday run; a wellness day moves it back onto the Thursday,
49+
# in which case say so, since "tomorrow" would be wrong and the reason is not obvious.
50+
BRANCH_TOMORROW = "We'll be branching from main tomorrow for release."
51+
BRANCH_TODAY_WELLNESS = (
52+
"We'll be branching from main today for release, as tomorrow is a wellness day."
53+
)
54+
BRANCH_TODAY = "We'll be branching from main today for release."
55+
56+
# One PR reads oddly with the plural ask, so it gets its own wording.
57+
ASK_ONE = "There is an automated PR pending, could it be reviewed and merged?"
58+
ASK_MANY = (
59+
"There are {count} automated PRs pending, could they be reviewed and merged?"
60+
)
61+
# The title carries the link, so the whole bullet is clickable. Slack has no list
62+
# markdown — a literal bullet character is how you get a list.
63+
LINE = "• <{url}|{title}>"
64+
65+
66+
def open_prs() -> list[dict]:
67+
"""The open PRs by AUTHOR, oldest first — the oldest has waited longest."""
68+
result = fetch_json(SEARCH_URL)
69+
if result.get("incomplete_results"):
70+
# A timed-out search returns a partial list, which would understate the queue.
71+
print("GitHub reported incomplete search results.", file=sys.stderr)
72+
return result["items"]
73+
74+
75+
THURSDAY = 3
76+
77+
78+
def branch_sentence(today: date) -> str | None:
79+
"""
80+
How to describe the upcoming branch, or None when there isn't one to describe.
81+
82+
None is the "no merge this week" case, and means nothing gets posted: the ask
83+
only makes sense when a branch is about to strand whatever is still open.
84+
"""
85+
days = ios_merge_days()
86+
87+
if today + timedelta(days=1) in days:
88+
return BRANCH_TOMORROW
89+
90+
if today in days:
91+
# Merge days are Fridays. One on a Thursday has been moved back off the
92+
# Friday, and a wellness day is the reason that happens.
93+
if today.weekday() == THURSDAY:
94+
return BRANCH_TODAY_WELLNESS
95+
return BRANCH_TODAY
96+
97+
return None
98+
99+
100+
def build_message(prs: list[dict], branch: str) -> str:
101+
"""The Slack message for a non-empty list of PRs."""
102+
ask = ASK_ONE if len(prs) == 1 else ASK_MANY.format(count=len(prs))
103+
lines = [f"{branch} {ask}"]
104+
lines += [
105+
LINE.format(url=pr["html_url"], title=pr["title"]) for pr in prs
106+
]
107+
return "\n".join(lines)
108+
109+
110+
def main() -> int:
111+
dry_run = env_flag("DRY_RUN")
112+
webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
113+
if not webhook_url and not dry_run:
114+
print("SLACK_WEBHOOK_URL is not set — nothing to do.", file=sys.stderr)
115+
return 1
116+
117+
test_date = os.environ.get("TEST_DATE")
118+
if test_date:
119+
today = datetime.strptime(test_date, "%Y-%m-%d").date()
120+
print(f"TEST MODE: pretending today is {today}")
121+
else:
122+
today = utc_today()
123+
124+
# Check the schedule first: with no branch coming there is nothing to ask for,
125+
# however many PRs are open.
126+
branch = branch_sentence(today)
127+
if branch is None:
128+
print(f"{today}: no iOS merge day today or tomorrow. Nothing to post.")
129+
return 0
130+
131+
prs = open_prs()
132+
if not prs:
133+
print(f"{today}: no open automated PRs in {REPO}. Nothing to post.")
134+
return 0
135+
136+
text = build_message(prs, branch)
137+
138+
if dry_run:
139+
print(f"DRY RUN — would post:\n{text}")
140+
return 0
141+
142+
post_to_slack(webhook_url, text)
143+
print(f"{today}: posted a reminder about {len(prs)} open automated PR(s).")
144+
return 0
145+
146+
147+
if __name__ == "__main__":
148+
raise SystemExit(main())

‎app/scripts/ios_release_slack_notifier.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,14 @@
4141

4242
from lib.env import env_flag
4343
from lib.fetch import RETRYABLE, fetch, fetch_json
44-
from lib.schedule import channel_versions, parse_date, today as utc_today
44+
from lib.schedule import (
45+
channel_versions,
46+
fetch_ios_schedule,
47+
parse_date,
48+
today as utc_today,
49+
)
4550
from lib.slack import post_to_slack
4651

47-
IOS_SCHEDULE_URL = "https://whattrainisitnow.com/api/release/schedule/ios/?version={}"
4852
# Firefox iOS App Store listing. Unknown query parameters form part of Apple's cache
4953
# key, so filling the unused one with the current time asks for a URL no edge node
5054
# holds yet, which gets us today's answer rather than yesterday's.
@@ -115,7 +119,7 @@ def expected_version(major: int, today: date) -> str | None:
115119
Dot releases shipped inbetween release_0 and release_1 use the pattern {major}.N.X, for example 153.0.1
116120
These versions are not covered by the slack notification automation since they are ad-hoc
117121
"""
118-
schedule = fetch_json(IOS_SCHEDULE_URL.format(major))
122+
schedule = fetch_ios_schedule(major)
119123

120124
# Sorted numerically once, so the lookup and the log line cannot disagree:
121125
# a plain sort would order release_10 before release_2.

‎app/scripts/lib/schedule.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,18 @@
44
from .fetch import fetch_json
55

66
API_URL = "https://whattrainisitnow.com/api/release/schedule/?version={}"
7+
IOS_API_URL = "https://whattrainisitnow.com/api/release/schedule/ios/?version={}"
8+
9+
# Which majors carry the merge days worth knowing about: Release and Beta.
10+
#
11+
# A major's merge days all fall before it ships, so the next one belongs to Beta.
12+
# Release is included because Nightly advances during merge day itself, which shifts
13+
# what Beta means on exactly the day the answer has to be right. Since Release always
14+
# trails Beta by one, the pair covers the same dates either side of that rollover.
15+
IOS_MERGE_CHANNELS = ("release", "beta")
716

817
_schedules: dict[str, dict] = {}
18+
_ios_schedules: dict[str, dict] = {}
919

1020

1121
def parse_date(value: str) -> datetime.date:
@@ -49,3 +59,32 @@ def channel_versions() -> dict[str, int]:
4959
"beta": nightly - 1,
5060
"nightly": nightly,
5161
}
62+
63+
64+
def fetch_ios_schedule(major: int | str) -> dict:
65+
"""Fetch a Firefox iOS major's milestone dates from the whattrainisitnow API."""
66+
key = str(major)
67+
if key not in _ios_schedules:
68+
_ios_schedules[key] = fetch_json(IOS_API_URL.format(key))
69+
70+
return _ios_schedules[key]
71+
72+
73+
def ios_merge_days() -> set[datetime.date]:
74+
"""
75+
Every iOS merge day in the Release and Beta schedules. See IOS_MERGE_CHANNELS.
76+
77+
A merge day is normally a Friday. One that lands on a Thursday has been moved
78+
back off a Friday that is a wellness day.
79+
"""
80+
versions = channel_versions()
81+
days: set[datetime.date] = set()
82+
for channel in IOS_MERGE_CHANNELS:
83+
schedule = fetch_ios_schedule(versions[channel])
84+
days |= {
85+
parse_date(value)
86+
for key, value in schedule.items()
87+
if key.startswith("merge_day")
88+
}
89+
90+
return days

0 commit comments

Comments
 (0)