|
| 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()) |
0 commit comments