From 6fe30f13edb05532c7dee15b49995202bb1b878b Mon Sep 17 00:00:00 2001 From: TheCodedKid Date: Thu, 2 Jul 2026 14:50:05 -0700 Subject: [PATCH] Add active PR display to Open Tasks Signed-off-by: TheCodedKid --- .gitignore | 1 + tools/generate_open_tasks.py | 68 ++++++++++++++++++++++-- tools/templates/open_tasks.md.j2 | 7 ++- tools/test_generate_open_tasks.py | 87 +++++++++++++++++++++++++++++-- 4 files changed, 153 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 8bfaac41..a7d41b95 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ .Trashes ehthumbs.db Thumbs.db +__pycache__ diff --git a/tools/generate_open_tasks.py b/tools/generate_open_tasks.py index 584268d9..29883c3d 100755 --- a/tools/generate_open_tasks.py +++ b/tools/generate_open_tasks.py @@ -120,6 +120,12 @@ class _Section(TypedDict): contribution_guide: str github_repo: str +class _LinkedPR(TypedDict, total=False): + number: int + url: str + title: str + isDraft: bool + class _Issue(TypedDict, total=False): repository: _Repository number: int @@ -127,20 +133,28 @@ class _Issue(TypedDict, total=False): url: str commentsCount: int body: str + linkedPRs: list[_LinkedPR] + +class _DisplayLinkedPR(TypedDict): + number: int + url: str + title: str + is_draft: bool class _DisplayIssue(TypedDict): number: int title: str url: str summary: str - commentsCount: int + comments_count: int + linked_prs: list[_DisplayLinkedPR] class _DisplaySection(_Section): issues: list[_DisplayIssue] def fetch_issues() -> list[_Issue]: - """Fetch all open `help wanted` issues across the org via the `gh` CLI.""" + """Fetch all open `help wanted` issues across the org, each with its `linkedPRs` var.""" result = subprocess.run( [ "gh", "search", "issues", @@ -152,7 +166,44 @@ def fetch_issues() -> list[_Issue]: ], capture_output=True, text=True, check=True, ) - return json.loads(result.stdout) # type: ignore[no-any-return] + issues: list[_Issue] = json.loads(result.stdout) + for issue in issues: + issue["linkedPRs"] = fetch_linked_prs( + issue["repository"]["nameWithOwner"], issue["number"] + ) + return issues + + +_LINKED_PRS_QUERY = """ +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + closedByPullRequestsReferences(first: 10, includeClosedPrs: false) { + nodes { number url title isDraft } + } + } + } +} +""" + + +def fetch_linked_prs(repo_full: str, number: int) -> list[_LinkedPR]: + """Return the open PRs linked to `repo_full#number`.""" + owner, repo = repo_full.split("/", 1) + result = subprocess.run( + [ + "gh", "api", "graphql", + "-f", f"query={_LINKED_PRS_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) + refs = (data.get("data", {}).get("repository", {}) or {}).get("issue") or {} + nodes: list[_LinkedPR] = (refs.get("closedByPullRequestsReferences") or {}).get("nodes") or [] + return nodes def load_issues_from_file(path: Path) -> list[_Issue]: @@ -238,7 +289,16 @@ def _build_template_sections(issues: list[_Issue]) -> list[_DisplaySection]: "title": html_escape(issue["title"]), "url": html_escape(issue["url"]), "summary": html_escape(make_summary(issue.get("body", ""))), - "commentsCount": issue.get("commentsCount", 0), + "comments_count": issue.get("commentsCount", 0), + "linked_prs": [ + { + "number": pr["number"], + "url": html_escape(pr["url"]), + "title": html_escape(pr["title"]), + "is_draft": pr.get("isDraft", False), + } + for pr in issue.get("linkedPRs", []) + ], } for issue in by_repo.get(section["slug"], []) ] diff --git a/tools/templates/open_tasks.md.j2 b/tools/templates/open_tasks.md.j2 index 26efa282..0917267f 100644 --- a/tools/templates/open_tasks.md.j2 +++ b/tools/templates/open_tasks.md.j2 @@ -10,7 +10,7 @@ updatedAt: {{ updated_at }} This page highlights open tasks across all Flipper One sub-project repositories that need community help or feedback. Each task corresponds to an issue labeled `help wanted` in its respective GitHub repository. -All tasks on this page are organized by sub-project and include a title, a short description, the number of comments, and a link to the task on GitHub. Each sub-project is managed by a team that defines its own contribution process — please review these guidelines before contributing. +All tasks on this page are organized by sub-project and include a title, a short description, any open pull requests already working on the task, the number of comments, and a link to the task on GitHub. Each sub-project is managed by a team that defines its own contribution process — please review these guidelines before contributing. :::hint{type="info"} Before you join an open task: @@ -42,9 +42,12 @@ Before you join an open task:

🟢 {{ issue.title }}

{{ issue.summary }}

+{% for pr in issue.linked_prs %} +

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

+{% endfor %} -

💬 {{ issue.commentsCount }}

+

💬 {{ issue.comments_count }}

{% endfor %} diff --git a/tools/test_generate_open_tasks.py b/tools/test_generate_open_tasks.py index b8a936ce..d92c5b4c 100644 --- a/tools/test_generate_open_tasks.py +++ b/tools/test_generate_open_tasks.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 +import json +import sys import unittest from pathlib import Path -import sys +from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parent)) -from generate_open_tasks import generate_page, make_summary, _Issue +from generate_open_tasks import fetch_linked_prs, generate_page, make_summary, _Issue class GenerateOpenTasksTest(unittest.TestCase): @@ -41,13 +43,90 @@ def test_generate_page_renders_empty_sections_and_comments_header(self) -> None: } ] - page = generate_page(issues, existing_created_at="Wed Apr 08 2026 17:29:37 GMT+0000 (Coordinated Universal Time)") + page = generate_page( + issues, + existing_created_at="Thu Apr 01 2077 17:29:37 GMT+0000 (Coordinated Universal Time)", + ) self.assertIn("# 🔌 Hardware tasks", page) - self.assertIn("The Hardware sub-project currently has no open `help wanted` tasks.", page) + self.assertIn( + "The Hardware sub-project currently has no open `help wanted` tasks.", page + ) self.assertIn("# 🐧 Linux (CPU Software) tasks", page) self.assertIn("

Comments

", page) + def test_generate_page_renders_linked_prs(self) -> None: + issues: list[_Issue] = [ + { + "repository": { + "nameWithOwner": "flipperdevices/flipperone-linux-build-scripts" + }, + "title": "Hardware Video Decoding", + "number": 13, + "url": "https://github.com/flipperdevices/flipperone-linux-build-scripts/issues/13", + "body": "Hardware video decoding lol.", + "commentsCount": 10, + "linkedPRs": [ + { + "number": 99, + "url": "https://github.com/flipperdevices/flipperone-linux-build-scripts/pull/99", + "title": 'Add "" decoder & fixes', + "isDraft": True, + } + ], + } + ] + + page = generate_page(issues) + + # PR-provided title contains ", <, >, & — all must be HTML-escaped. + self.assertIn( + '

🔀 #99 Add "<MPP>" decoder & fixes (draft)

', + page, + ) + + def test_fetch_linked_prs_parses_nodes(self) -> None: + payload = { + "data": { + "repository": { + "issue": { + "closedByPullRequestsReferences": { + "nodes": [ + { + "number": 99, + "url": "u", + "title": "t", + "isDraft": True, + } + ] + } + } + } + } + } + with mock.patch("generate_open_tasks.subprocess.run") as run: + run.return_value = mock.Mock(stdout=json.dumps(payload)) + prs = fetch_linked_prs("flipperdevices/repo", 13) + + self.assertEqual( + prs, + payload["data"]["repository"]["issue"]["closedByPullRequestsReferences"][ + "nodes" + ], + ) + # owner/repo split correctly passed to gh + args = run.call_args[0][0] + self.assertIn("owner=flipperdevices", args) + self.assertIn("repo=repo", args) + self.assertIn("number=13", args) + + def test_fetch_linked_prs_handles_missing_issue(self) -> None: + with mock.patch("generate_open_tasks.subprocess.run") as run: + run.return_value = mock.Mock( + stdout=json.dumps({"data": {"repository": {"issue": None}}}) + ) + self.assertEqual(fetch_linked_prs("o/r", 1), []) + if __name__ == "__main__": unittest.main()