Files
upstream-zulip/tools/closed-by-commits
upstream-mirror 64898b2c79
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

439 lines
14 KiB
Python
Executable File

#!/usr/bin/env -S uv run --script --frozen --only-group release-tools # -*-python-*-
import json
import re
import time
import urllib.parse
from collections import defaultdict
from dataclasses import dataclass, field
from functools import cache
from typing import Annotated, Any
import requests
import typer
from github import Auth, Github
from rich.console import Console
from rich.progress import Progress
from typing_extensions import override
def encode_hash_component(s: str) -> str:
hash_replacements = {
"%": ".",
"(": ".28",
")": ".29",
".": ".2E",
}
encoded = urllib.parse.quote(s, safe="*")
return "".join(hash_replacements.get(c, c) for c in encoded)
@cache
def search_czo_for_number(prefix: str, number: int) -> frozenset[str]:
params = {
"anchor": "newest",
"num_before": "100",
"num_after": "0",
"narrow": json.dumps(
[
{"negated": False, "operator": "search", "operand": f'"#{prefix}{number}"'},
{"negated": False, "operator": "channels", "operand": "web-public"},
]
),
}
try:
response = requests.get("https://chat.zulip.org/json/messages", params=params, timeout=30)
response.raise_for_status()
data = response.json()
messages = data.get("messages", [])
# Extract unique topic URLs
urls = set()
for msg in messages:
stream_id = msg.get("stream_id")
display_recipient = msg.get("display_recipient")
subject = msg.get("subject")
assert stream_id
assert display_recipient
encoded_recipient = encode_hash_component(display_recipient.replace(" ", "-"))
encoded_subject = encode_hash_component(subject)
url = f"https://chat.zulip.org/#narrow/channel/{stream_id}-{encoded_recipient}/topic/{encoded_subject}"
urls.add(url)
return frozenset(urls)
except requests.exceptions.RequestException as e:
assert e.response
if e.response.status_code != 429:
raise
retry_after = int(e.response.headers["Retry-After"]) + 1
time.sleep(retry_after)
return search_czo_for_number(prefix, number)
@dataclass
class Issue:
number: int
title: str
czo_urls: set[str] = field(default_factory=set)
closed_by_prs: list["PullRequest"] = field(default_factory=list)
duplicate_issue_ids: set[int] = field(default_factory=set)
@override
def __hash__(self) -> int:
return hash(self.number)
@override
def __eq__(self, other: object) -> bool:
return isinstance(other, Issue) and self.number == other.number
@dataclass
class PullRequest:
number: int
title: str
czo_urls: set[str] = field(default_factory=set)
@override
def __hash__(self) -> int:
return hash(self.number)
@override
def __eq__(self, other: object) -> bool:
return isinstance(other, PullRequest) and self.number == other.number
class CommitRangeAnalyzer:
COMMIT_PRS_QUERY = """
query($oid: GitObjectID!, $repo: String!) {
repository(owner: "zulip", name: $repo) {
object(oid: $oid) {
... on Commit {
messageBody
associatedPullRequests(first: 10) {
nodes {
number
title
url
body
comments(first: 100) {
nodes {
body
}
}
closingIssuesReferences(first: 50) {
nodes {
number
title
url
body
comments(first: 100) {
nodes {
body
}
}
timelineItems(first:100, itemTypes:MARKED_AS_DUPLICATE_EVENT) {
... on IssueTimelineItemsConnection {
nodes {
... on MarkedAsDuplicateEvent {
duplicate {
... on Issue {
number
}
}
}
}
}
}
}
}
}
}
}
}
}
}
"""
ISSUE_QUERY = """
query($number: Int!, $repo: String!) {
repository(owner: "zulip", name: $repo) {
issue(number: $number) {
number
title
body
comments(first: 100) {
nodes {
body
}
}
timelineItems(first:100, itemTypes:MARKED_AS_DUPLICATE_EVENT) {
... on IssueTimelineItemsConnection {
nodes {
... on MarkedAsDuplicateEvent {
duplicate {
... on Issue {
number
}
}
}
}
}
}
}
}
}
"""
def __init__(self, token: str, reponame: str, czo_issue_prefix: str) -> None:
self.github = Github(auth=Auth.Token(token))
self.reponame = reponame
self.czo_issue_prefix = czo_issue_prefix
@staticmethod
def _extract_czo_urls(text: str | None) -> set[str]:
if not text:
return set()
matches = re.findall(r"https://chat\.zulip\.org/[^\s\)\]\>]+", text)
urls = set()
for url in matches:
# We strip off and remove /with/... and /near/... to
# reduce the number of unique links which are generated.
parsed_url = re.match(r"(.*)/topic/([^/]+)(/(near|with)/.*)?$", url)
if not parsed_url:
continue
urls.add(
parsed_url[1]
+ "/topic/"
# Normalize the topic by decoding and re-encoding
+ encode_hash_component(urllib.parse.unquote(parsed_url[2].replace(".", "%")))
)
return urls
@staticmethod
def _extract_issue_numbers(reponame: str, text: str | None) -> set[int]:
if not text:
return set()
# https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword
pattern = rf"\b(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?):?\s+(?:zulip/{reponame})?#(\d+)\b"
matches = re.findall(pattern, text, re.IGNORECASE)
return {int(num) for num in matches}
def _issue_from_node(self, issue_node: dict[str, Any], console: Console) -> Issue:
issue = Issue(
number=issue_node["number"],
title=issue_node["title"].strip(),
)
issue_comments = issue_node.get("comments", {}).get("nodes", [])
for node in [issue_node, *issue_comments]:
issue.czo_urls.update(self._extract_czo_urls(node.get("body")))
issue.czo_urls.update(search_czo_for_number(self.czo_issue_prefix, issue.number))
issue.duplicate_issue_ids = {
event["duplicate"]["number"] for event in issue_node["timelineItems"]["nodes"]
}
for issue_id in issue.duplicate_issue_ids:
if duplicate_info := self._fetch_issue(issue_id, console):
issue.czo_urls.update(duplicate_info.czo_urls)
issue.duplicate_issue_ids.update(duplicate_info.duplicate_issue_ids)
return issue
def _fetch_issue(self, number: int, console: Console) -> Issue | None:
"""Fetch issue metadata from GitHub."""
try:
_, result = self.github.requester.graphql_query(
self.ISSUE_QUERY, {"number": number, "repo": self.reponame}
)
if "errors" in result:
console.log(
"Failed to fetch zulip/%s#%d: %s", self.reponame, number, result["errors"]
)
return None
data = result.get("data", {})
issue_node = data.get("repository", {}).get("issue")
if not issue_node:
return None
return self._issue_from_node(issue_node, console)
except Exception:
return None
def get_issues_for_commit(
self, commit_sha: str, console: Console
) -> tuple[dict[Issue, set[PullRequest]], set[int]]:
_, result = self.github.requester.graphql_query(
self.COMMIT_PRS_QUERY, {"oid": commit_sha, "repo": self.reponame}
)
if "errors" in result:
error_messages = [e.get("message", str(e)) for e in result["errors"]]
raise RuntimeError(f"GraphQL errors: {', '.join(error_messages)}")
data = result.get("data", {})
if not data.get("repository", {}).get("object"):
return dict(), set()
commit_obj = data["repository"]["object"]
commit_message = commit_obj.get("messageBody", "")
pr_nodes = commit_obj.get("associatedPullRequests", {}).get("nodes", [])
results: dict[Issue, set[PullRequest]] = defaultdict(set)
all_seen_prs: set[int] = set()
for pr_node in pr_nodes:
pr = PullRequest(
number=pr_node["number"],
title=pr_node["title"].strip(),
)
all_seen_prs.add(pr.number)
pr_comments = pr_node.get("comments", {}).get("nodes", [])
for node in [pr_node, *pr_comments]:
pr.czo_urls.update(self._extract_czo_urls(node.get("body")))
pr.czo_urls.update(search_czo_for_number(self.czo_issue_prefix, pr.number))
# Get issues from PR metadata
issues_dict = {}
issue_nodes = pr_node.get("closingIssuesReferences", {}).get("nodes", [])
for issue_node in issue_nodes:
if issue_node is None:
continue
issue = self._issue_from_node(issue_node, console)
issues_dict[issue.number] = issue
# Extract additional issue numbers from commit message
for issue_num in self._extract_issue_numbers(self.reponame, commit_message):
if issue_num not in issues_dict:
maybe_issue = self._fetch_issue(issue_num, console)
if maybe_issue is None:
continue
issues_dict[issue_num] = maybe_issue
for issue in issues_dict.values():
results[issue].add(pr)
if not pr_nodes:
for issue_num in self._extract_issue_numbers(self.reponame, commit_message):
maybe_issue = self._fetch_issue(issue_num, console)
if maybe_issue is None:
continue
results[maybe_issue].update()
return results, all_seen_prs
def analyze_range(self, base: str, head: str) -> list[Issue]:
console = Console(stderr=True, log_path=False)
repository = self.github.get_repo(f"zulip/{self.reponame}")
comparison = repository.compare(base, head)
commit_shas = [commit.sha for commit in comparison.commits]
console.log(f"Found {len(commit_shas)} commits")
all_seen_prs = set()
issue_to_prs: dict[Issue, set[PullRequest]] = defaultdict(set)
with Progress(console=console) as progress:
task = progress.add_task("Processing commits...", total=len(commit_shas))
for sha in commit_shas:
progress.console.log(f"Processing {sha}")
this_issue_to_prs, this_seen_prs = self.get_issues_for_commit(sha, console)
for issue, prs in this_issue_to_prs.items():
issue_to_prs[issue].update(prs)
all_seen_prs.update(this_seen_prs)
progress.advance(task)
for issue, prs in issue_to_prs.items():
for pr in prs:
issue.czo_urls.update(pr.czo_urls)
issue.closed_by_prs = sorted(prs, key=lambda p: p.number)
unique_prs = len({pr for prs in issue_to_prs.values() for pr in prs})
console.log(
f"Found {len(all_seen_prs)} unique PRs, of which {unique_prs} PRs closed {len(issue_to_prs)} issues",
)
return sorted(issue_to_prs.keys(), key=lambda x: x.number)
def validate_github_token(value: str) -> str:
# https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github#githubs-token-formats
if value.startswith("github_"):
return value
if re.match(r"gh[pousr]_", value):
return value
raise typer.BadParameter("Github access tokens start with `github_`, or `gh`")
from enum import Enum
class ZulipRepo(str, Enum):
zulip = ("zulip",)
flutter = "zulip-flutter"
repo_issue_prefixes = {
ZulipRepo.zulip: "",
ZulipRepo.flutter: "F",
}
def main(
base_commit: Annotated[str, typer.Argument(help="Commit-ish, resolved on the server")],
head_commit: Annotated[str, typer.Argument(help="Commit-ish, resolved on the server")],
token: Annotated[
str,
typer.Option(
metavar="TOKEN",
envvar="GITHUB_TOKEN",
show_envvar=True,
callback=validate_github_token,
help=(
"Github access token; can be a fine-grained personal access token "
"with read-only access to 'Public repositories'. "
"See https://github.com/settings/personal-access-tokens/new"
),
),
],
repo: ZulipRepo = ZulipRepo.zulip,
) -> int:
"""Find issues which are closed in a commit range."""
lines = []
prefix = repo_issue_prefixes[repo]
analyzer = CommitRangeAnalyzer(token, repo.value, prefix)
for issue in analyzer.analyze_range(base_commit, head_commit):
lines.append(f"#### #{prefix}{issue.number}: {issue.title}")
if issue.duplicate_issue_ids:
lines.append(
" - **Duplicates:** "
+ ", ".join(f"#{prefix}{number}" for number in issue.duplicate_issue_ids)
)
lines.extend(
f" - **Closed by:** #{prefix}{pr.number}: {pr.title}" for pr in issue.closed_by_prs
)
lines.extend(f" - {czo_url}" for czo_url in sorted(issue.czo_urls))
lines.append("")
print("\n".join(lines))
return 0
if __name__ == "__main__":
typer.run(main)