From 39649a987e53956cbd8a74cb762baaae52e9e872 Mon Sep 17 00:00:00 2001 From: TheCodedKid Date: Tue, 14 Jul 2026 23:17:26 -0700 Subject: [PATCH 1/2] created community pr page and tests --- tools/generate_open_tasks.py | 118 +++++++++++++++++++++++++++++- tools/templates/open_tasks.md.j2 | 29 ++++++++ tools/test_generate_open_tasks.py | 63 +++++++++++++++- 3 files changed, 207 insertions(+), 3 deletions(-) diff --git a/tools/generate_open_tasks.py b/tools/generate_open_tasks.py index 29883c3..90f1273 100755 --- a/tools/generate_open_tasks.py +++ b/tools/generate_open_tasks.py @@ -103,6 +103,8 @@ LABEL = "help wanted" ORG = "flipperdevices" +SECTION_SLUGS = {section["slug"] for section in SECTIONS} +ORG_MEMBER_ASSOCIATIONS = {"MEMBER", "OWNER"} SCRIPT_URL = "https://github.com/flipperdevices/flipperone-docs/blob/public-release/tools/generate_open_tasks.py" TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" TEMPLATE_NAME = "open_tasks.md.j2" @@ -152,6 +154,29 @@ class _DisplayIssue(TypedDict): class _DisplaySection(_Section): issues: list[_DisplayIssue] +class _Author(TypedDict, total=False): + login: str + is_bot: bool + +class _CommunityPR(TypedDict, total=False): + repository: _Repository + number: int + title: str + url: str + isDraft: bool + commentsCount: int + author: _Author + authorAssociation: str + +class _DisplayCommunityPR(TypedDict): + number: int + url: str + title: str + repo: str + author: str + comments_count: int + is_draft: bool + def fetch_issues() -> list[_Issue]: """Fetch all open `help wanted` issues across the org, each with its `linkedPRs` var.""" @@ -206,6 +231,69 @@ def fetch_linked_prs(repo_full: str, number: int) -> list[_LinkedPR]: return nodes +def is_community_pr(pr: _CommunityPR) -> bool: + """True if the PR is from a non-org, non-bot author.""" + if pr.get("author", {}).get("is_bot"): + return False + return pr.get("authorAssociation", "").upper() not in ORG_MEMBER_ASSOCIATIONS + + +_LINKED_ISSUE_QUERY = """ +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 1) { totalCount } + } + } +} +""" + + +def pr_has_linked_issue(repo_full: str, number: int) -> bool: + """True if the PR closes or links at least one issue.""" + owner, repo = repo_full.split("/", 1) + result = subprocess.run( + [ + "gh", "api", "graphql", + "-f", f"query={_LINKED_ISSUE_QUERY}", + "-F", f"owner={owner}", + "-F", f"repo={repo}", + "-F", f"number={number}", + ], + capture_output=True, text=True, check=True, + ) + data = json.loads(result.stdout) + pr = ((data.get("data", {}).get("repository", {}) or {}).get("pullRequest")) or {} + return ((pr.get("closingIssuesReferences") or {}).get("totalCount") or 0) > 0 + + +def fetch_community_prs() -> list[_CommunityPR]: + """Fetch open PRs in the sub-project repos that are from community authors and + not linked to any issue.""" + result = subprocess.run( + [ + "gh", "search", "prs", + "--owner", ORG, + "--state", "open", + "--limit", "200", + "--json", "repository,number,title,url,isDraft,commentsCount,author,authorAssociation", + ], + capture_output=True, text=True, check=True, + ) + prs: list[_CommunityPR] = json.loads(result.stdout) + community: list[_CommunityPR] = [] + for pr in prs: + repo_full = pr["repository"]["nameWithOwner"] + if repo_full not in SECTION_SLUGS: + continue + if not is_community_pr(pr): + continue + if pr_has_linked_issue(repo_full, pr["number"]): + continue + community.append(pr) + return community + + def load_issues_from_file(path: Path) -> list[_Issue]: """For local testing without `gh`: load a JSON array of issues from a file.""" return json.loads(path.read_text(encoding="utf-8")) # type: ignore[no-any-return] @@ -307,6 +395,25 @@ def _build_template_sections(issues: list[_Issue]) -> list[_DisplaySection]: return sections +def _build_community_prs(prs: list[_CommunityPR]) -> list[_DisplayCommunityPR]: + """Prepare community PR data for the Open Tasks Jinja template.""" + display: list[_DisplayCommunityPR] = [] + for pr in prs: + repo_full = pr["repository"]["nameWithOwner"] + repo_short = repo_full.split("/", 1)[1] if "/" in repo_full else repo_full + display.append({ + "number": pr["number"], + "url": html_escape(pr["url"]), + "title": html_escape(pr["title"]), + "repo": html_escape(repo_short), + "author": html_escape(pr.get("author", {}).get("login", "")), + "comments_count": pr.get("commentsCount", 0), + "is_draft": pr.get("isDraft", False), + }) + display.sort(key=lambda p: (p["repo"], -p["number"])) + return display + + def _render_open_tasks_template(**context: object) -> str: env = Environment( loader=FileSystemLoader(TEMPLATE_DIR), @@ -318,7 +425,11 @@ def _render_open_tasks_template(**context: object) -> str: return env.get_template(TEMPLATE_NAME).render(**context) -def generate_page(issues: list[_Issue], existing_created_at: str | None = None) -> str: +def generate_page( + issues: list[_Issue], + existing_created_at: str | None = None, + community_prs: list[_CommunityPR] | None = None, +) -> str: now = datetime.now(timezone.utc) created_at = existing_created_at or archbee_timestamp(now) updated_at = archbee_timestamp(now) @@ -328,6 +439,7 @@ def generate_page(issues: list[_Issue], existing_created_at: str | None = None) updated_at=updated_at, script_url=SCRIPT_URL, sections=_build_template_sections(issues), + community_prs=_build_community_prs(community_prs or []), ) return page.rstrip() + "\n" @@ -349,7 +461,9 @@ def main() -> int: issues = (load_issues_from_file(args.input_json) if args.input_json else fetch_issues()) - page = generate_page(issues, existing_created_at=existing_created_at) + community_prs = [] if args.input_json else fetch_community_prs() + page = generate_page(issues, existing_created_at=existing_created_at, + community_prs=community_prs) if args.out: if args.out.exists(): diff --git a/tools/templates/open_tasks.md.j2 b/tools/templates/open_tasks.md.j2 index 0917267..4dd66e9 100644 --- a/tools/templates/open_tasks.md.j2 +++ b/tools/templates/open_tasks.md.j2 @@ -66,3 +66,32 @@ Learn what's going on in the {{ section.title }} sub-project: ::: {% endfor %} +{% if community_prs %} +*** + +# 🔀 Community pull requests + +Open pull requests from community contributors that aren't linked to any task above. Help review and discuss them. + + + + + + +{% for pr in community_prs %} + + + + +{% endfor %} +
+

Pull request

+
+

Comments

+
+

🔀 {{ pr.repo }}#{{ pr.number }} {{ pr.title }}{% if pr.is_draft %} (draft){% endif %}

+

by {{ pr.author }}

+
+

💬 {{ pr.comments_count }}

+
+{% endif %} diff --git a/tools/test_generate_open_tasks.py b/tools/test_generate_open_tasks.py index d92c5b4..78fd08a 100644 --- a/tools/test_generate_open_tasks.py +++ b/tools/test_generate_open_tasks.py @@ -7,7 +7,15 @@ from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parent)) -from generate_open_tasks import fetch_linked_prs, generate_page, make_summary, _Issue +from generate_open_tasks import ( + fetch_linked_prs, + generate_page, + is_community_pr, + make_summary, + pr_has_linked_issue, + _CommunityPR, + _Issue, +) class GenerateOpenTasksTest(unittest.TestCase): @@ -127,6 +135,59 @@ def test_fetch_linked_prs_handles_missing_issue(self) -> None: ) self.assertEqual(fetch_linked_prs("o/r", 1), []) + def test_is_community_pr_filters_members_and_bots(self) -> None: + cases: list[tuple[_CommunityPR, bool]] = [ + ({"authorAssociation": "MEMBER", "author": {"login": "a"}}, False), + ({"authorAssociation": "OWNER", "author": {"login": "b"}}, False), + ({"authorAssociation": "NONE", "author": {"is_bot": True}}, False), + ({"authorAssociation": "NONE", "author": {"login": "c"}}, True), + ({"authorAssociation": "CONTRIBUTOR", "author": {"login": "d"}}, True), + ] + for pr, expected in cases: + with self.subTest(pr=pr): + self.assertEqual(is_community_pr(pr), expected) + + def test_pr_has_linked_issue_reads_total_count(self) -> None: + cases: list[tuple[dict[str, object] | None, bool]] = [ + ({"closingIssuesReferences": {"totalCount": 0}}, False), + ({"closingIssuesReferences": {"totalCount": 2}}, True), + (None, False), # PR not found + ] + for pull_request, expected in cases: + payload = {"data": {"repository": {"pullRequest": pull_request}}} + with self.subTest(pull_request=pull_request): + with mock.patch("generate_open_tasks.subprocess.run") as run: + run.return_value = mock.Mock(stdout=json.dumps(payload)) + self.assertEqual( + pr_has_linked_issue("flipperdevices/repo", 5), expected + ) + + def test_generate_page_renders_community_prs(self) -> None: + community_prs: list[_CommunityPR] = [ + { + "repository": {"nameWithOwner": "flipperdevices/flipperone-docs"}, + "number": 356, + "title": 'Fix "typo" & stuff', + "url": "https://github.com/flipperdevices/flipperone-docs/pull/356", + "isDraft": False, + "commentsCount": 3, + "author": {"login": "someone"}, + "authorAssociation": "NONE", + } + ] + + page = generate_page([], community_prs=community_prs) + + self.assertIn("# 🔀 Community pull requests", page) + self.assertIn( + '

🔀 flipperone-docs#356 Fix "typo" & stuff

', + page, + ) + self.assertIn("

by someone

", page) + + def test_generate_page_omits_community_section_when_empty(self) -> None: + self.assertNotIn("# 🔀 Community pull requests", generate_page([])) + if __name__ == "__main__": unittest.main() From a0aad8dbd33fc6255e3255061169fa7a7317ad79 Mon Sep 17 00:00:00 2001 From: TheCodedKid Date: Wed, 22 Jul 2026 21:45:33 -0700 Subject: [PATCH 2/2] removed per pr call, added batch and no input test Signed-off-by: TheCodedKid --- tools/generate_open_tasks.py | 55 +++++++++++++++++-------------- tools/test_generate_open_tasks.py | 49 ++++++++++++++++++--------- 2 files changed, 64 insertions(+), 40 deletions(-) diff --git a/tools/generate_open_tasks.py b/tools/generate_open_tasks.py index 90f1273..472db72 100755 --- a/tools/generate_open_tasks.py +++ b/tools/generate_open_tasks.py @@ -159,6 +159,7 @@ class _Author(TypedDict, total=False): is_bot: bool class _CommunityPR(TypedDict, total=False): + id: str repository: _Repository number: int title: str @@ -238,10 +239,11 @@ def is_community_pr(pr: _CommunityPR) -> bool: return pr.get("authorAssociation", "").upper() not in ORG_MEMBER_ASSOCIATIONS -_LINKED_ISSUE_QUERY = """ -query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { +_LINKED_ISSUES_QUERY = """ +query($ids: [ID!]!) { + nodes(ids: $ids) { + ... on PullRequest { + id closingIssuesReferences(first: 1) { totalCount } } } @@ -249,22 +251,26 @@ def is_community_pr(pr: _CommunityPR) -> bool: """ -def pr_has_linked_issue(repo_full: str, number: int) -> bool: - """True if the PR closes or links at least one issue.""" - owner, repo = repo_full.split("/", 1) +def fetch_pr_ids_with_linked_issues(pr_ids: list[str]) -> set[str]: + """Return IDs of PRs that close or link an issue in one GraphQL request.""" + if not pr_ids: + return set() result = subprocess.run( [ "gh", "api", "graphql", - "-f", f"query={_LINKED_ISSUE_QUERY}", - "-F", f"owner={owner}", - "-F", f"repo={repo}", - "-F", f"number={number}", + "-f", f"query={_LINKED_ISSUES_QUERY}", + *(arg for pr_id in pr_ids for arg in ("-F", f"ids[]={pr_id}")), ], capture_output=True, text=True, check=True, ) data = json.loads(result.stdout) - pr = ((data.get("data", {}).get("repository", {}) or {}).get("pullRequest")) or {} - return ((pr.get("closingIssuesReferences") or {}).get("totalCount") or 0) > 0 + nodes = data.get("data", {}).get("nodes") or [] + return { + node["id"] + for node in nodes + if node + and ((node.get("closingIssuesReferences") or {}).get("totalCount") or 0) > 0 + } def fetch_community_prs() -> list[_CommunityPR]: @@ -276,22 +282,21 @@ def fetch_community_prs() -> list[_CommunityPR]: "--owner", ORG, "--state", "open", "--limit", "200", - "--json", "repository,number,title,url,isDraft,commentsCount,author,authorAssociation", + "--json", "id,repository,number,title,url,isDraft,commentsCount,author,authorAssociation", ], capture_output=True, text=True, check=True, ) prs: list[_CommunityPR] = json.loads(result.stdout) - community: list[_CommunityPR] = [] - for pr in prs: - repo_full = pr["repository"]["nameWithOwner"] - if repo_full not in SECTION_SLUGS: - continue - if not is_community_pr(pr): - continue - if pr_has_linked_issue(repo_full, pr["number"]): - continue - community.append(pr) - return community + candidates = [ + pr + for pr in prs + if pr["repository"]["nameWithOwner"] in SECTION_SLUGS + and is_community_pr(pr) + ] + linked_pr_ids = fetch_pr_ids_with_linked_issues( + [pr["id"] for pr in candidates] + ) + return [pr for pr in candidates if pr["id"] not in linked_pr_ids] def load_issues_from_file(path: Path) -> list[_Issue]: diff --git a/tools/test_generate_open_tasks.py b/tools/test_generate_open_tasks.py index 78fd08a..6cc6892 100644 --- a/tools/test_generate_open_tasks.py +++ b/tools/test_generate_open_tasks.py @@ -9,10 +9,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from generate_open_tasks import ( fetch_linked_prs, + fetch_pr_ids_with_linked_issues, generate_page, is_community_pr, make_summary, - pr_has_linked_issue, _CommunityPR, _Issue, ) @@ -147,20 +147,39 @@ def test_is_community_pr_filters_members_and_bots(self) -> None: with self.subTest(pr=pr): self.assertEqual(is_community_pr(pr), expected) - def test_pr_has_linked_issue_reads_total_count(self) -> None: - cases: list[tuple[dict[str, object] | None, bool]] = [ - ({"closingIssuesReferences": {"totalCount": 0}}, False), - ({"closingIssuesReferences": {"totalCount": 2}}, True), - (None, False), # PR not found - ] - for pull_request, expected in cases: - payload = {"data": {"repository": {"pullRequest": pull_request}}} - with self.subTest(pull_request=pull_request): - with mock.patch("generate_open_tasks.subprocess.run") as run: - run.return_value = mock.Mock(stdout=json.dumps(payload)) - self.assertEqual( - pr_has_linked_issue("flipperdevices/repo", 5), expected - ) + def test_fetch_pr_ids_with_linked_issues_batches_prs(self) -> None: + payload = { + "data": { + "nodes": [ + { + "id": "PR_1", + "closingIssuesReferences": {"totalCount": 0}, + }, + { + "id": "PR_2", + "closingIssuesReferences": {"totalCount": 2}, + }, + None, + ] + } + } + with mock.patch("generate_open_tasks.subprocess.run") as run: + run.return_value = mock.Mock(stdout=json.dumps(payload)) + linked_ids = fetch_pr_ids_with_linked_issues( + ["PR_1", "PR_2", "PR_MISSING"] + ) + + self.assertEqual(linked_ids, {"PR_2"}) + run.assert_called_once() + args = run.call_args[0][0] + self.assertIn("ids[]=PR_1", args) + self.assertIn("ids[]=PR_2", args) + self.assertIn("ids[]=PR_MISSING", args) + + def test_fetch_pr_ids_with_linked_issues_skips_empty_request(self) -> None: + with mock.patch("generate_open_tasks.subprocess.run") as run: + self.assertEqual(fetch_pr_ids_with_linked_issues([]), set()) + run.assert_not_called() def test_generate_page_renders_community_prs(self) -> None: community_prs: list[_CommunityPR] = [