Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
.Trashes
ehthumbs.db
Thumbs.db
__pycache__
68 changes: 64 additions & 4 deletions tools/generate_open_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,27 +120,41 @@ 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
title: str
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",
Expand All @@ -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]:
Expand Down Expand Up @@ -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"], [])
]
Expand Down
7 changes: 5 additions & 2 deletions tools/templates/open_tasks.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -42,9 +42,12 @@ Before you join an open task:
<td align="left" colSpan="1" rowSpan="1">
<p>🟢 <a href="{{ issue.url }}">{{ issue.title }}</a></p>
<p>{{ issue.summary }}</p>
{% for pr in issue.linked_prs %}
<p>🔀 <a href="{{ pr.url }}">#{{ pr.number }}</a> {{ pr.title }}{% if pr.is_draft %} (draft){% endif %}</p>
{% endfor %}
</td>
<td align="center" colSpan="1" rowSpan="1">
<p>💬 {{ issue.commentsCount }}</p>
<p>💬 {{ issue.comments_count }}</p>
</td>
</tr>
{% endfor %}
Expand Down
87 changes: 83 additions & 4 deletions tools/test_generate_open_tasks.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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("<p><strong>Comments</strong></p>", 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 "<MPP>" decoder & fixes',
"isDraft": True,
}
],
}
]

page = generate_page(issues)

# PR-provided title contains ", <, >, & — all must be HTML-escaped.
self.assertIn(
'<p>🔀 <a href="https://github.com/flipperdevices/flipperone-linux-build-scripts/pull/99">#99</a> Add &quot;&lt;MPP&gt;&quot; decoder &amp; fixes (draft)</p>',
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()