From 5ca17829be45a1327ee61b08166eab2f58911c0e Mon Sep 17 00:00:00 2001 From: sxl-lucky7 <60656474+sxl-lucky7@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:38:59 +0800 Subject: [PATCH] fix(skills): exclude pull requests from GitHub issue research (#7516) Co-authored-by: Willem Jiang --- README.md | 2 + skills/public/github-deep-research/SKILL.md | 2 + .../scripts/github_api.py | 18 ++- tests/skills/test_github_deep_research.py | 5 +- tests/skills/test_github_research_issues.py | 127 ++++++++++++++++++ 5 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 tests/skills/test_github_research_issues.py diff --git a/README.md b/README.md index d878b4d338..54495afd78 100644 --- a/README.md +++ b/README.md @@ -1729,6 +1729,8 @@ File reads accept raw repository-relative paths and preserve spaces, Unicode, `#`, `?`, and literal percent signs with either HTTP client. Directory listings ask for the repository's own default branch, so a repository that uses neither `main` nor `master` reports its current tree instead of a stale branch. +The `issues` command excludes pull requests and follows additional pages to fill +its requested issue limit; the `prs` command remains available for pull requests. Users can explicitly activate an enabled skill for a single turn by starting the request with `/skill-name`, for example `/data-analysis analyze uploads/foo.csv`. DeerFlow loads that skill's `SKILL.md` as hidden current-turn context while leaving the base prompt limited to skill metadata. Slash activation respects disabled skills, custom-agent skill whitelists, and existing channel commands such as `/new` and `/help`. diff --git a/skills/public/github-deep-research/SKILL.md b/skills/public/github-deep-research/SKILL.md index 2add158655..694db9b3f0 100644 --- a/skills/public/github-deep-research/SKILL.md +++ b/skills/public/github-deep-research/SKILL.md @@ -56,6 +56,8 @@ python /path/to/skill/scripts/github_api.py tree - prs - releases +The `issues` command returns repository issues only, excluding pull requests. It follows additional pages when needed to fill the requested issue limit; use `prs` to research pull requests separately. + **Round 2 - Discovery (3-5 web_search)** - Get overview and identify key terms - Find official website/repo diff --git a/skills/public/github-deep-research/scripts/github_api.py b/skills/public/github-deep-research/scripts/github_api.py index 007c7af468..ac09e57dce 100644 --- a/skills/public/github-deep-research/scripts/github_api.py +++ b/skills/public/github-deep-research/scripts/github_api.py @@ -172,16 +172,28 @@ class GitHubAPI: labels: Optional[str] = None, ) -> List[Dict]: """ - Get repository issues. + Get repository issues, excluding pull requests. Args: state: 'open', 'closed', or 'all' labels: Comma-separated label names """ - params = {"state": state, "per_page": min(limit, 100)} + limit = min(limit, 100) + if limit <= 0: + return [] + params = {"state": state, "per_page": limit} if labels: params["labels"] = labels - return self._get(f"/repos/{owner}/{repo}/issues", params) + issues = [] + page = 1 + while len(issues) < limit: + items = self._get(f"/repos/{owner}/{repo}/issues", params) + issues.extend(item for item in items if "pull_request" not in item) + if len(items) < limit: + break + page += 1 + params["page"] = page + return issues[:limit] def get_pull_requests( self, owner: str, repo: str, state: str = "all", limit: int = 30 diff --git a/tests/skills/test_github_deep_research.py b/tests/skills/test_github_deep_research.py index 0952fe5f29..23a78dc3ce 100644 --- a/tests/skills/test_github_deep_research.py +++ b/tests/skills/test_github_deep_research.py @@ -29,7 +29,7 @@ def fallback_client(monkeypatch): finally: connection.close() captured.update(url=request.full_url, timeout=timeout, headers=request.headers) - response = io.BytesIO(b'{"ok": true}') + response = io.BytesIO(captured.get("body", b'{"ok": true}')) response.status = 200 return response @@ -102,10 +102,11 @@ def test_fallback_search_preserves_query(fallback_client, query): def test_fallback_issues_preserves_filters(fallback_client): client, captured = fallback_client + captured["body"] = b'[{"number": 1}]' assert client.get_issues( "owner", "repo", state="open", limit=10, labels="bug,help wanted" - ) == {"ok": True} + ) == [{"number": 1}] assert parse_qs(urlsplit(captured["url"]).query) == { "state": ["open"], diff --git a/tests/skills/test_github_research_issues.py b/tests/skills/test_github_research_issues.py new file mode 100644 index 0000000000..8b6ca0bb51 --- /dev/null +++ b/tests/skills/test_github_research_issues.py @@ -0,0 +1,127 @@ +"""Keep pull requests out of repository issue research, across both transports.""" + +import io +import json +import runpy +import sys +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest + + +@pytest.fixture(params=["requests", "urllib"]) +def issue_client(request, monkeypatch): + if request.param == "urllib": + monkeypatch.setitem(sys.modules, "requests", None) + script = ( + Path(__file__).resolve().parents[2] + / "skills/public/github-deep-research/scripts/github_api.py" + ) + module = runpy.run_path(str(script)) + pages = [] + calls = [] + + if request.param == "urllib": + + def fake_urlopen(prepared, timeout): + calls.append(prepared.full_url) + payload, status = pages.pop(0) + data = json.dumps(payload).encode() + if status >= 400: + raise urllib.error.HTTPError(prepared.full_url, status, "error", {}, io.BytesIO(data)) + response = io.BytesIO(data) + response.status = status + return response + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + else: + requests = pytest.importorskip("requests") + + def fake_send(session, prepared, **kwargs): + calls.append(prepared.url) + payload, status = pages.pop(0) + response = requests.Response() + response.status_code = status + response._content = json.dumps(payload).encode() + return response + + monkeypatch.setattr(requests.Session, "send", fake_send) + + return module["GitHubAPI"](), pages, calls + + +def test_issue_limit_counts_issues_instead_of_pull_requests(issue_client): + client, pages, calls = issue_client + pages.extend([ + ([{"number": 10, "pull_request": {}}, {"number": 9}], 200), + ([{"number": 8, "pull_request": {}}, {"number": 7}], 200), + ]) + + assert client.get_issues("owner", "repo", state="open", limit=2, labels="bug,help wanted") == [{"number": 9}, {"number": 7}] + assert len(calls) == 2 + for index, url in enumerate(calls): + assert urlsplit(url).path == "/repos/owner/repo/issues" + parameters = parse_qs(urlsplit(url).query) + assert parameters["state"] == ["open"] + assert parameters["labels"] == ["bug,help wanted"] + assert parameters["per_page"] == ["2"] + assert parameters.get("page", ["1"]) == [str(index + 1)] + + +def test_issue_search_continues_after_a_page_of_only_pull_requests(issue_client): + client, pages, calls = issue_client + pages.extend([ + ([{"number": 5, "pull_request": {}}, {"number": 4, "pull_request": {}}], 200), + ([{"number": 3}], 200), + ]) + + assert client.get_issues("owner", "repo", limit=2) == [{"number": 3}] + assert len(calls) == 2 + + +def test_issue_search_stops_at_an_empty_page(issue_client): + client, pages, calls = issue_client + pages.extend([([{"number": 2, "pull_request": {}}], 200), ([], 200)]) + + assert client.get_issues("owner", "repo", limit=1) == [] + assert len(calls) == 2 + + +def test_issue_search_does_not_fetch_an_unneeded_page(issue_client): + client, pages, calls = issue_client + pages.append(([{"number": 2}, {"number": 1}], 200)) + + assert client.get_issues("owner", "repo", limit=2) == [{"number": 2}, {"number": 1}] + assert len(calls) == 1 + + +def test_last_page_does_not_exceed_the_requested_issue_limit(issue_client): + client, pages, calls = issue_client + pages.extend([ + ([{"number": 4}, {"number": 3, "pull_request": {}}], 200), + ([{"number": 2}, {"number": 1}], 200), + ]) + + assert client.get_issues("owner", "repo", limit=2) == [{"number": 4}, {"number": 2}] + assert len(calls) == 2 + + +@pytest.mark.parametrize("limit", [0, -1]) +def test_nonpositive_issue_limit_does_not_make_a_request(issue_client, limit): + client, pages, calls = issue_client + pages.append(([], 200)) + + assert client.get_issues("owner", "repo", limit=limit) == [] + assert calls == [] + + +def test_later_page_failure_is_not_returned_as_partial_research(issue_client): + client, pages, calls = issue_client + pages.extend([([{"number": 3}, {"number": 2, "pull_request": {}}], 200), ({"message": "rate limited"}, 403)]) + + with pytest.raises(Exception, match="403"): + client.get_issues("owner", "repo", limit=2) + assert len(calls) == 2