From ea29c9c390884f24911c827e0875a6607b348425 Mon Sep 17 00:00:00 2001 From: PrithvijitBose Date: Wed, 9 Sep 2026 10:41:53 +0530 Subject: [PATCH 1/6] feat: add /issue command to list recent open issues --- QUICK_START_GUIDE.txt | 1 + README.md | 1 + src/ghdcbot/adapters/github/rest.py | 16 ++ src/ghdcbot/bot.py | 86 +++++++++ src/ghdcbot/engine/issue_list.py | 261 ++++++++++++++++++++++++++++ tests/test_issue_list.py | 167 ++++++++++++++++++ 6 files changed, 532 insertions(+) create mode 100644 src/ghdcbot/engine/issue_list.py create mode 100644 tests/test_issue_list.py diff --git a/QUICK_START_GUIDE.txt b/QUICK_START_GUIDE.txt index 7ef9a951..0485b5c2 100644 --- a/QUICK_START_GUIDE.txt +++ b/QUICK_START_GUIDE.txt @@ -34,6 +34,7 @@ Tips: /summary Contribution metrics (7d / 30d); optional member /open-prs List currently open PRs for a contributor /pr Recent PRs by closed / merged / open (count, skip) +/issue Recent open issues in project channel (excluding PRs; limit) /connect-social Attach X or LinkedIn username/URL to your profile /disconnect-social Remove X or LinkedIn from your profile /unlink Remove your GitHub link diff --git a/README.md b/README.md index 0c79f98e..51e8ca85 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ Contributor-facing cheat sheet: [`QUICK_START_GUIDE.txt`](QUICK_START_GUIDE.txt) - `/summary` - Show contribution metrics (7 and 30 days); optional Discord member for another verified contributor - `/open-prs` - List a contributor's currently open PRs in configured repos - `/pr` - List a contributor's recent PRs grouped by closed / merged / open (`count` N, optional `skip` M) +- `/issue` - List recent open issues in the project channel (excluding PRs) with configurable `limit` (default 10, max 50) - `/pr-status` - Show PR health (CI, CodeRabbit review threads, merge conflicts, approval state) for a single PR or multi-PR org dashboard (`show_all:True`, optional `skip` M). Accessible to all server members by default (cooldown: one invocation per user every 5 seconds); optionally gateable via `discord.command_permissions.pr-status`. Dashboard requests are capped at 25 PRs per page. Each PR uses a small number of REST calls (pull request, reviews, check runs) plus paginated GraphQL queries for review threads, so a full dashboard page costs roughly 100 or more API requests. When querying a single PR, omitting `repo:` probes each configured repository with one additional REST call to auto-detect the repository (capped at 25 candidate repositories, resulting in a maximum of 25 probe calls). ### Sync (mentor-only) diff --git a/src/ghdcbot/adapters/github/rest.py b/src/ghdcbot/adapters/github/rest.py index 541abe79..a7627612 100644 --- a/src/ghdcbot/adapters/github/rest.py +++ b/src/ghdcbot/adapters/github/rest.py @@ -622,6 +622,22 @@ def delete_file( ) return False + def list_repo_open_issues(self, owner: str, repo: str, per_page: int = 100) -> list[dict]: + """Fetch open issues for a repository, excluding pull requests. + + Returns list of issue dicts, newest first. + """ + issues: list[dict] = [] + params = {"state": "open", "sort": "created", "direction": "desc", "per_page": min(per_page, 100)} + for page in self._paginate(f"/repos/{owner}/{repo}/issues", params=params): + for item in page: + if "pull_request" in item: + continue + issues.append(item) + if len(issues) >= per_page: + return issues + return issues + def get_pull_request(self, owner: str, repo: str, pr_number: int) -> dict | None: """Fetch a single pull request by number. diff --git a/src/ghdcbot/bot.py b/src/ghdcbot/bot.py index d9a72ff3..20643ea3 100644 --- a/src/ghdcbot/bot.py +++ b/src/ghdcbot/bot.py @@ -24,6 +24,12 @@ resolve_discord_to_github, resolve_github_to_discord, ) +from ghdcbot.engine.issue_list import ( + clamp_issue_limit, + filter_open_issues, + format_issue_list_messages, + resolve_repo_for_issue, +) from ghdcbot.engine.metrics import ( build_contribution_summary_message, get_contribution_metrics, @@ -967,6 +973,86 @@ async def pr_list_repo_autocomplete( ) return [app_commands.Choice(name=r, value=r) for r in suggestions] + @tree.command( + name="issue", + description="List recent open issues in the project channel (excluding PRs)", + guild=discord.Object(id=guild_id), + ) + @app_commands.describe( + limit="How many recent open issues to show (optional, default 10, max 50)", + ) + @app_commands.checks.cooldown(1, 15.0) + async def issue_cmd( + interaction: discord.Interaction, + limit: app_commands.Range[int, 1, 50] = 10, + ) -> None: + await interaction.response.defer(ephemeral=True) + + channel_id = interaction.channel_id + channel_name = getattr(interaction.channel, "name", None) + resolved_repo, error_msg = resolve_repo_for_issue( + config=config, + channel_id=channel_id, + channel_name=channel_name, + ) + if error_msg or not resolved_repo: + await interaction.followup.send( + error_msg or "❌ Unable to determine repository.", + ephemeral=True, + ) + return + + effective_limit = clamp_issue_limit(limit) + logger.info( + "/issue requested", + extra={ + "repo": resolved_repo, + "requested_limit": limit, + "effective_limit": effective_limit, + "user_id": str(interaction.user.id), + }, + ) + + try: + list_issues = getattr(github_adapter, "list_repo_open_issues", None) + if not callable(list_issues): + await interaction.followup.send( + "❌ This GitHub adapter cannot list repository issues.", + ephemeral=True, + ) + return + raw_issues = await asyncio.to_thread( + list_issues, + config.github.org, + resolved_repo, + effective_limit, + ) + except Exception: + logger.exception( + "Failed to list issues for /issue", + extra={"repo": resolved_repo, "user_id": str(interaction.user.id)}, + ) + await interaction.followup.send( + "❌ Error fetching issues. Please try again later.", + ephemeral=True, + ) + return + + issues = filter_open_issues(raw_issues, limit=effective_limit) + messages = format_issue_list_messages( + issues=issues, + org=config.github.org, + repo=resolved_repo, + limit=effective_limit, + storage=storage, + ) + for message in messages: + await interaction.followup.send( + message, + ephemeral=True, + suppress_embeds=True, + ) + @tree.command( name="who-is", description="Lookup a GitHub username to find their verified Discord account", diff --git a/src/ghdcbot/engine/issue_list.py b/src/ghdcbot/engine/issue_list.py new file mode 100644 index 00000000..9c67bf9a --- /dev/null +++ b/src/ghdcbot/engine/issue_list.py @@ -0,0 +1,261 @@ +"""Helpers for /issue: list recent open issues for a repository.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Sequence +from typing import Any + +from ghdcbot.engine.issue_assignment import resolve_github_to_discord +from ghdcbot.engine.pr_status import ( + _cfg_get, + get_configured_repo_names, + is_repo_allowed, +) + +_MAX_MESSAGE_CHARS = 1900 +_DEFAULT_LIMIT = 10 +_MAX_LIMIT = 50 + + +def clamp_issue_limit(limit: int | None = None) -> int: + """Normalize limit for /issue (default 10, min 1, max 50).""" + raw = _DEFAULT_LIMIT if limit is None else int(limit) + return max(1, min(raw, _MAX_LIMIT)) + + +def filter_open_issues(items: Iterable[dict], limit: int = 10) -> list[dict]: + """Filter out PRs and non-open issues, returning up to ``limit`` issues.""" + n = clamp_issue_limit(limit) + filtered: list[dict] = [] + for item in items: + # GitHub /issues endpoint returns both issues and PRs. + # PRs always have a "pull_request" key in the payload. + if "pull_request" in item: + continue + state = str(item.get("state") or "").strip().lower() + if state != "open": + continue + filtered.append(item) + if len(filtered) >= n: + break + return filtered + + +def format_issue_description(body: str | None, max_length: int = 180) -> str: + """Format issue description into a clean, compact blockquote snippet.""" + if not body or not body.strip(): + return "> _No description provided._" + + text = body.strip() + # Strip HTML comments + text = re.sub(r"", "", text, flags=re.DOTALL) + # Strip image markdown ![...](...) + text = re.sub(r"!\[.*?\]\(.*?\)", "", text) + # Collapse consecutive whitespace and newlines + lines = [line.strip() for line in text.splitlines() if line.strip()] + cleaned = " ".join(lines).strip() + + if not cleaned: + return "> _No description provided._" + + if len(cleaned) > max_length: + cleaned = cleaned[: max_length - 3].rstrip() + "..." + + return f"> {cleaned}" + + +def _suppress_discord_embed(url: str) -> str: + """Wrap URL in <> so Discord does not render a link preview embed.""" + text = (url or "").strip() + if not text: + return text + if text.startswith("<") and text.endswith(">"): + return text + return f"<{text}>" + + +def format_single_issue_entry( + issue: dict, + org: str, + repo: str, + storage: Any = None, +) -> list[str]: + """Format a single issue into markdown lines. + + Includes: + - Issue number & title with link (<> embeds suppressed) + - Status (Open 🟢) + - Who opened it (resolving Discord ID if linked) + - Comments count + - Labels (if any) + - Description snippet + """ + number = issue.get("number", "?") + title = (issue.get("title") or "No title").strip() + url = issue.get("html_url") or f"https://github.com/{org}/{repo}/issues/{number}" + suppressed_url = _suppress_discord_embed(str(url)) + + # Author resolution + author = (issue.get("user") or {}).get("login") or "unknown" + author_display = author + if storage is not None: + discord_id = resolve_github_to_discord(storage, author) + if discord_id: + author_display = f"<@{discord_id}> ({author})" + + # Comments count + comments = int(issue.get("comments") or 0) + comments_str = f"💬 {comments} comment" + ("s" if comments != 1 else "") + + # Labels + raw_labels = issue.get("labels") or [] + label_names = [ + lbl.get("name") + for lbl in raw_labels + if isinstance(lbl, dict) and lbl.get("name") + ] + label_str = f"🏷️ {', '.join(label_names)}" if label_names else "" + + meta_parts = [ + "Status: `Open 🟢`", + f"Opened by: {author_display}", + comments_str, + ] + if label_str: + meta_parts.append(label_str) + + title_line = f"• [#{number}]({suppressed_url}) — **{title}**" + meta_line = " " + " • ".join(meta_parts) + desc_line = f" {format_issue_description(issue.get('body'))}" + + return [title_line, meta_line, desc_line] + + +def _chunk_message_lines(lines: Sequence[str], *, max_chars: int = _MAX_MESSAGE_CHARS) -> list[str]: + """Pack lines into Discord-sized messages; continue with a short marker.""" + if not lines: + return [""] + + chunks: list[str] = [] + current: list[str] = [] + current_len = 0 + + def flush() -> None: + nonlocal current, current_len + if not current: + return + chunks.append("\n".join(current).rstrip()) + current = [] + current_len = 0 + + for raw_line in lines: + line = raw_line + if len(line) > max_chars: + line = line[: max_chars - 1] + "…" + + add_len = len(line) + (1 if current else 0) + if current and current_len + add_len > max_chars: + flush() + cont = "*(continued)*" + current = [cont] + current_len = len(cont) + add_len = len(line) + 1 + + if current: + current.append(line) + current_len += add_len + else: + current = [line] + current_len = len(line) + + flush() + return chunks or [""] + + +def format_issue_list_messages( + issues: Sequence[dict], + org: str, + repo: str, + limit: int = 10, + storage: Any = None, +) -> list[str]: + """Build one or more Discord messages under the length limit.""" + n = clamp_issue_limit(limit) + if not issues: + return [f"No open issues found in **{repo}**."] + + header = f"Recent Open Issues in **{repo}** — last {len(issues)} (limit {n})" + lines = [header, ""] + + for issue in issues: + lines.extend(format_single_issue_entry(issue, org=org, repo=repo, storage=storage)) + lines.append("") + + while lines and lines[-1] == "": + lines.pop() + + return _chunk_message_lines(lines, max_chars=_MAX_MESSAGE_CHARS) + + +def resolve_repo_for_issue( + config: Any, + channel_id: int | str | None = None, + channel_name: str | None = None, + repo: str | None = None, +) -> tuple[str | None, str | None]: + """Resolve repository name for an issue listing query. + + Resolution order: + 1. Explicit ``repo`` argument (validated against config filters). + 2. Matching channel in ``config.discord.pr_open_channels``. + 3. Matching channel name with configured repositories. + 4. Exactly one configured repository in Gitcord config. + 5. Returns error prompting user to specify repository. + """ + repo_filter = None + if config: + github_cfg = _cfg_get(config, "github") + if github_cfg: + repo_filter = _cfg_get(github_cfg, "repos") + + # 1. Explicit repo argument + if repo and repo.strip(): + cleaned = repo.strip() + if not is_repo_allowed(repo_filter, cleaned): + return None, f"❌ Repository **{cleaned}** is not allowed by Gitcord configuration." + return cleaned, None + + # Available configured repos + configured_repos = [ + candidate + for candidate in get_configured_repo_names(config) + if is_repo_allowed(repo_filter, candidate) + ] + + # 2. Check config.discord.pr_open_channels + if channel_id and config: + discord_cfg = _cfg_get(config, "discord") + if discord_cfg: + pr_open_channels = _cfg_get(discord_cfg, "pr_open_channels") + if isinstance(pr_open_channels, dict): + for r, cid in pr_open_channels.items(): + if str(cid) == str(channel_id) and is_repo_allowed(repo_filter, r): + return r, None + + # 3. Check channel_name matching configured repo + if channel_name: + clean_chan = channel_name.strip().lstrip("#").lower().replace("-", "_") + for r in configured_repos: + if r.lower().replace("-", "_") == clean_chan: + return r, None + + # 4. If exactly one repository is configured + if len(configured_repos) == 1: + return configured_repos[0], None + + # 5. Cannot resolve + if not configured_repos: + return None, "❌ No repositories configured in Gitcord." + + return None, "❌ Could not auto-detect repository from this channel. Please run this command in a project channel." diff --git a/tests/test_issue_list.py b/tests/test_issue_list.py new file mode 100644 index 00000000..f6bd8219 --- /dev/null +++ b/tests/test_issue_list.py @@ -0,0 +1,167 @@ +"""Tests for /issue command engine helpers (issue_list).""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from ghdcbot.engine.issue_list import ( + clamp_issue_limit, + filter_open_issues, + format_issue_description, + format_issue_list_messages, + format_single_issue_entry, + resolve_repo_for_issue, +) + + +def test_clamp_issue_limit(): + assert clamp_issue_limit(None) == 10 + assert clamp_issue_limit(0) == 1 + assert clamp_issue_limit(-5) == 1 + assert clamp_issue_limit(25) == 25 + assert clamp_issue_limit(50) == 50 + assert clamp_issue_limit(100) == 50 + + +def test_filter_open_issues_excludes_prs_and_closed(): + items = [ + {"number": 1, "title": "Issue 1", "state": "open"}, + {"number": 2, "title": "PR 2", "state": "open", "pull_request": {"url": "https://..."}}, + {"number": 3, "title": "Issue 3", "state": "closed"}, + {"number": 4, "title": "Issue 4", "state": "open"}, + {"number": 5, "title": "Issue 5", "state": "open"}, + ] + filtered = filter_open_issues(items, limit=2) + assert len(filtered) == 2 + assert [i["number"] for i in filtered] == [1, 4] + + +def test_format_issue_description(): + assert format_issue_description(None) == "> _No description provided._" + assert format_issue_description("") == "> _No description provided._" + assert format_issue_description(" \n\n ") == "> _No description provided._" + + body_with_html_comment = "This is the actual issue content." + assert format_issue_description(body_with_html_comment) == "> This is the actual issue content." + + long_body = "A" * 300 + formatted = format_issue_description(long_body, max_length=50) + assert len(formatted) <= 55 + assert formatted.endswith("...") + assert formatted.startswith("> ") + + +def test_format_single_issue_entry_with_author_and_labels(): + issue = { + "number": 42, + "title": "Add dark mode toggle", + "html_url": "https://github.com/org/repo/issues/42", + "state": "open", + "user": {"login": "octocat"}, + "comments": 3, + "labels": [{"name": "enhancement"}, {"name": "ui"}], + "body": "Please add dark mode.", + } + lines = format_single_issue_entry(issue, org="org", repo="repo", storage=None) + assert len(lines) == 3 + assert lines[0] == "• [#42]() — **Add dark mode toggle**" + assert "Status: `Open 🟢`" in lines[1] + assert "Opened by: octocat" in lines[1] + assert "💬 3 comments" in lines[1] + assert "🏷️ enhancement, ui" in lines[1] + assert lines[2] == " > Please add dark mode." + + +def test_format_single_issue_entry_with_discord_link(): + mapping = MagicMock() + mapping.github_user = "alice" + mapping.discord_user_id = "123456789" + storage = MagicMock() + storage.list_verified_identity_mappings.return_value = [mapping] + + issue = { + "number": 10, + "title": "Fix bug", + "html_url": "https://github.com/org/repo/issues/10", + "state": "open", + "user": {"login": "alice"}, + "comments": 1, + "labels": [], + "body": None, + } + lines = format_single_issue_entry(issue, org="org", repo="repo", storage=storage) + assert "Opened by: <@123456789> (alice)" in lines[1] + assert "💬 1 comment" in lines[1] + assert "🏷️" not in lines[1] + assert lines[2] == " > _No description provided._" + + +def test_format_issue_list_messages_chunking(): + issues = [ + { + "number": i, + "title": f"Issue title {i} " + "X" * 100, + "html_url": f"https://github.com/org/repo/issues/{i}", + "state": "open", + "user": {"login": f"user{i}"}, + "comments": i, + "labels": [{"name": "bug"}], + "body": "Body content " * 20, + } + for i in range(1, 20) + ] + messages = format_issue_list_messages(issues, org="org", repo="repo", limit=20) + assert len(messages) > 1 + for msg in messages: + assert len(msg) <= 1900 + + +def test_resolve_repo_for_issue(): + config = MagicMock() + config.github.repos.mode = "allow" + config.github.repos.names = ["Knowledge-Agent"] + config.discord.pr_open_channels = {"Knowledge-Agent": "11223344"} + config.repo_contributor_roles = {} + + # Explicit repo allowed + repo, err = resolve_repo_for_issue(config, repo="Knowledge-Agent") + assert repo == "Knowledge-Agent" + assert err is None + + # Explicit repo not allowed + repo, err = resolve_repo_for_issue(config, repo="Unknown-Repo") + assert repo is None + assert "not allowed" in err + + # Auto-detect via channel ID + repo, err = resolve_repo_for_issue(config, channel_id="11223344") + assert repo == "Knowledge-Agent" + assert err is None + + # Auto-detect via channel name + repo, err = resolve_repo_for_issue(config, channel_name="knowledge-agent") + assert repo == "Knowledge-Agent" + assert err is None + + # Auto-detect via single repo + repo, err = resolve_repo_for_issue(config) + assert repo == "Knowledge-Agent" + assert err is None + + +def test_github_rest_adapter_list_repo_open_issues(): + from ghdcbot.adapters.github.rest import GitHubRestAdapter + + adapter = GitHubRestAdapter("fake-token", "fake-org", "https://api.github.com") + fake_items = [ + {"number": 1, "title": "Real issue 1", "state": "open"}, + {"number": 2, "title": "Pull request 2", "state": "open", "pull_request": {"url": "..."}}, + {"number": 3, "title": "Real issue 3", "state": "open"}, + ] + + adapter._paginate = MagicMock(return_value=[fake_items]) + issues = adapter.list_repo_open_issues("fake-org", "fake-repo", per_page=10) + + assert len(issues) == 2 + assert [i["number"] for i in issues] == [1, 3] + From f8b99691a201fe4b5cff14e4731f5f12976c8f8b Mon Sep 17 00:00:00 2001 From: PrithvijitBose Date: Wed, 9 Sep 2026 11:25:54 +0530 Subject: [PATCH 2/6] fix: improve issue pagination limits, error propagation, and config access --- src/ghdcbot/adapters/github/rest.py | 40 +++++++++-- src/ghdcbot/bot.py | 7 ++ src/ghdcbot/config/__init__.py | 4 ++ src/ghdcbot/config/access.py | 16 +++++ src/ghdcbot/engine/issue_list.py | 10 +-- src/ghdcbot/engine/pr_status.py | 38 +++++----- tests/test_issue_list.py | 106 ++++++++++++++++++++++++++-- tests/test_pr_status.py | 26 ++++--- 8 files changed, 200 insertions(+), 47 deletions(-) create mode 100644 src/ghdcbot/config/access.py diff --git a/src/ghdcbot/adapters/github/rest.py b/src/ghdcbot/adapters/github/rest.py index a7627612..8c63d16a 100644 --- a/src/ghdcbot/adapters/github/rest.py +++ b/src/ghdcbot/adapters/github/rest.py @@ -113,6 +113,7 @@ def __init__(self, token: str | Callable[[], str], org: str, api_base: str) -> N self._sync_cached_repos: list[dict] | None = None self._sync_request_count = 0 self._sync_repos_processed = 0 + self._last_paginate_error = False self._client = build_github_httpx_client(token, api_base=api_base, timeout=30.0) def close(self) -> None: @@ -622,20 +623,43 @@ def delete_file( ) return False - def list_repo_open_issues(self, owner: str, repo: str, per_page: int = 100) -> list[dict]: + def list_repo_open_issues( + self, + owner: str, + repo: str, + limit: int = 100, + per_page: int = 100, + ) -> list[dict] | None: """Fetch open issues for a repository, excluding pull requests. - Returns list of issue dicts, newest first. + :param owner: Repository owner/org. + :param repo: Repository name. + :param limit: Maximum number of open issues to collect across pages. + :param per_page: Page size for each GitHub API request (clamped to 1..100). + :return: List of issue dicts, newest first, or None on fetch error. """ + if limit <= 0: + return [] + issues: list[dict] = [] - params = {"state": "open", "sort": "created", "direction": "desc", "per_page": min(per_page, 100)} - for page in self._paginate(f"/repos/{owner}/{repo}/issues", params=params): + page_size = max(1, min(per_page, 100)) + params = {"state": "open", "sort": "created", "direction": "desc", "per_page": page_size} + pages = self._paginate(f"/repos/{owner}/{repo}/issues", params=params) + if pages is None: + return None + for page in pages: + if page is None: + return None for item in page: if "pull_request" in item: continue issues.append(item) - if len(issues) >= per_page: + if len(issues) >= limit: return issues + + if getattr(self, "_last_paginate_error", False): + return None + return issues def get_pull_request(self, owner: str, repo: str, pr_number: int) -> dict | None: @@ -1791,12 +1815,15 @@ def _list_repo_open_prs(self, repo: dict) -> Iterable[dict]: } def _paginate(self, path: str, params: dict) -> Iterator[list]: + self._last_paginate_error = False page = 1 while True: response = self._request("GET", path, params={**params, "page": page}) if response is None: + self._last_paginate_error = True return if response.status_code != 200: + self._last_paginate_error = True self._logger.warning( "GitHub request failed", extra={"path": path, "status_code": response.status_code}, @@ -1827,12 +1854,15 @@ def _list_repos_from_path(self, path: str) -> tuple[list[dict], int | None]: return repos, response.status_code def _paginate_from_page(self, path: str, params: dict, start_page: int) -> Iterator[list]: + self._last_paginate_error = False page = start_page while True: response = self._request("GET", path, params={**params, "page": page}) if response is None: + self._last_paginate_error = True return if response.status_code != 200: + self._last_paginate_error = True self._logger.warning( "GitHub request failed", extra={"path": path, "status_code": response.status_code}, diff --git a/src/ghdcbot/bot.py b/src/ghdcbot/bot.py index 20643ea3..e09d1942 100644 --- a/src/ghdcbot/bot.py +++ b/src/ghdcbot/bot.py @@ -1038,6 +1038,13 @@ async def issue_cmd( ) return + if raw_issues is None: + await interaction.followup.send( + "❌ Error fetching issues. Please try again later.", + ephemeral=True, + ) + return + issues = filter_open_issues(raw_issues, limit=effective_limit) messages = format_issue_list_messages( issues=issues, diff --git a/src/ghdcbot/config/__init__.py b/src/ghdcbot/config/__init__.py index 28c6e458..693bc3c6 100644 --- a/src/ghdcbot/config/__init__.py +++ b/src/ghdcbot/config/__init__.py @@ -1 +1,5 @@ """config package.""" + +from ghdcbot.config.access import cfg_get + +__all__ = ["cfg_get"] diff --git a/src/ghdcbot/config/access.py b/src/ghdcbot/config/access.py new file mode 100644 index 00000000..6d4a4ac9 --- /dev/null +++ b/src/ghdcbot/config/access.py @@ -0,0 +1,16 @@ +"""Shared configuration access helpers.""" + +from __future__ import annotations + +from typing import Any + + +def cfg_get(target: Any, key: str, default: Any = None) -> Any: + """Retrieve an attribute or dict key from target, returning default if absent or None.""" + if target is None: + return default + if isinstance(target, dict): + val = target.get(key) + else: + val = getattr(target, key, None) + return default if val is None else val diff --git a/src/ghdcbot/engine/issue_list.py b/src/ghdcbot/engine/issue_list.py index 9c67bf9a..8f887055 100644 --- a/src/ghdcbot/engine/issue_list.py +++ b/src/ghdcbot/engine/issue_list.py @@ -6,9 +6,9 @@ from collections.abc import Iterable, Sequence from typing import Any +from ghdcbot.config.access import cfg_get from ghdcbot.engine.issue_assignment import resolve_github_to_discord from ghdcbot.engine.pr_status import ( - _cfg_get, get_configured_repo_names, is_repo_allowed, ) @@ -215,9 +215,9 @@ def resolve_repo_for_issue( """ repo_filter = None if config: - github_cfg = _cfg_get(config, "github") + github_cfg = cfg_get(config, "github") if github_cfg: - repo_filter = _cfg_get(github_cfg, "repos") + repo_filter = cfg_get(github_cfg, "repos") # 1. Explicit repo argument if repo and repo.strip(): @@ -235,9 +235,9 @@ def resolve_repo_for_issue( # 2. Check config.discord.pr_open_channels if channel_id and config: - discord_cfg = _cfg_get(config, "discord") + discord_cfg = cfg_get(config, "discord") if discord_cfg: - pr_open_channels = _cfg_get(discord_cfg, "pr_open_channels") + pr_open_channels = cfg_get(discord_cfg, "pr_open_channels") if isinstance(pr_open_channels, dict): for r, cid in pr_open_channels.items(): if str(cid) == str(channel_id) and is_repo_allowed(repo_filter, r): diff --git a/src/ghdcbot/engine/pr_status.py b/src/ghdcbot/engine/pr_status.py index d72cf8c2..0604bed5 100644 --- a/src/ghdcbot/engine/pr_status.py +++ b/src/ghdcbot/engine/pr_status.py @@ -11,8 +11,13 @@ from dataclasses import dataclass from typing import Any +from ghdcbot.config.access import cfg_get + logger = logging.getLogger(__name__) +# Backward compatibility alias for _cfg_get +_cfg_get = cfg_get + # Default CodeRabbit bot logins (matches notifications.py pattern). _DEFAULT_CODERABBIT_BOT_LOGINS = ["coderabbitai", "coderabbitai[bot]"] @@ -45,17 +50,6 @@ def is_repo_allowed(repo_filter: Any, repo_name: str) -> bool: return True -def _cfg_get(target: Any, key: str, default: Any = None) -> Any: - """Retrieve an attribute or dict key from target, returning default if absent or None.""" - if target is None: - return default - if isinstance(target, dict): - val = target.get(key) - else: - val = getattr(target, key, None) - return default if val is None else val - - def get_configured_repo_names(config: Any) -> list[str]: """Extract repository names configured in Gitcord configuration. @@ -80,31 +74,31 @@ def _add(name: Any) -> None: return repo_names # 1. github.repos.names (allow mode) - github_cfg = _cfg_get(config, "github") + github_cfg = cfg_get(config, "github") if github_cfg: - repos_cfg = _cfg_get(github_cfg, "repos") + repos_cfg = cfg_get(github_cfg, "repos") if repos_cfg: if isinstance(repos_cfg, (list, tuple)): for r in repos_cfg: _add(r) else: - mode = _cfg_get(repos_cfg, "mode", "allow") or "allow" - names = _cfg_get(repos_cfg, "names") + mode = cfg_get(repos_cfg, "mode", "allow") or "allow" + names = cfg_get(repos_cfg, "names") if mode == "allow" and isinstance(names, (list, tuple)): for r in names: _add(r) # 2. discord.pr_open_channels - discord_cfg = _cfg_get(config, "discord") + discord_cfg = cfg_get(config, "discord") if discord_cfg: - pr_open_channels = _cfg_get(discord_cfg, "pr_open_channels") + pr_open_channels = cfg_get(discord_cfg, "pr_open_channels") if isinstance(pr_open_channels, dict): for r in pr_open_channels: _add(r) # 3. repo_contributor_roles - contributor_roles = _cfg_get(config, "repo_contributor_roles") + contributor_roles = cfg_get(config, "repo_contributor_roles") if isinstance(contributor_roles, dict): for r in contributor_roles: _add(r) @@ -150,9 +144,9 @@ async def resolve_repo_for_pr( """ repo_filter = None if config: - github_cfg = _cfg_get(config, "github") + github_cfg = cfg_get(config, "github") if github_cfg: - repo_filter = _cfg_get(github_cfg, "repos") + repo_filter = cfg_get(github_cfg, "repos") # Case 1: User explicitly provided repo if repo and repo.strip(): @@ -180,9 +174,9 @@ async def resolve_repo_for_pr( # Multiple repos configured: check which one contains this PR number org = "" if config: - github_cfg = _cfg_get(config, "github") + github_cfg = cfg_get(config, "github") if github_cfg: - org = _cfg_get(github_cfg, "org", "") or "" + org = cfg_get(github_cfg, "org", "") or "" candidates = configured_repos[:RESOLVE_REPO_MAX_CANDIDATES] semaphore = asyncio.Semaphore(RESOLVE_REPO_MAX_CONCURRENCY) diff --git a/tests/test_issue_list.py b/tests/test_issue_list.py index f6bd8219..3ad236e2 100644 --- a/tests/test_issue_list.py +++ b/tests/test_issue_list.py @@ -152,16 +152,112 @@ def test_resolve_repo_for_issue(): def test_github_rest_adapter_list_repo_open_issues(): from ghdcbot.adapters.github.rest import GitHubRestAdapter - adapter = GitHubRestAdapter("fake-token", "fake-org", "https://api.github.com") fake_items = [ {"number": 1, "title": "Real issue 1", "state": "open"}, {"number": 2, "title": "Pull request 2", "state": "open", "pull_request": {"url": "..."}}, {"number": 3, "title": "Real issue 3", "state": "open"}, ] - adapter._paginate = MagicMock(return_value=[fake_items]) - issues = adapter.list_repo_open_issues("fake-org", "fake-repo", per_page=10) + with GitHubRestAdapter("fake-token", "fake-org", "https://api.github.com") as adapter: + adapter._paginate = MagicMock(return_value=[fake_items]) + issues = adapter.list_repo_open_issues("fake-org", "fake-repo", limit=10) + + assert len(issues) == 2 + assert [i["number"] for i in issues] == [1, 3] + adapter._paginate.assert_called_once_with( + "/repos/fake-org/fake-repo/issues", + params={"state": "open", "sort": "created", "direction": "desc", "per_page": 100}, + ) + + # Verify per_page specifies GitHub API page size + adapter._paginate.reset_mock() + adapter._paginate.return_value = [fake_items] + adapter.list_repo_open_issues("fake-org", "fake-repo", limit=10, per_page=25) + adapter._paginate.assert_called_once_with( + "/repos/fake-org/fake-repo/issues", + params={"state": "open", "sort": "created", "direction": "desc", "per_page": 25}, + ) + + # Verify per_page > 100 is clamped and does not exceed limit + adapter._paginate.reset_mock() + adapter._paginate.return_value = [fake_items] + limited = adapter.list_repo_open_issues("fake-org", "fake-repo", limit=1, per_page=200) + assert len(limited) == 1 + assert limited[0]["number"] == 1 + adapter._paginate.assert_called_once_with( + "/repos/fake-org/fake-repo/issues", + params={"state": "open", "sort": "created", "direction": "desc", "per_page": 100}, + ) + + # Verify non-positive limit returns empty list without making API calls + adapter._paginate.reset_mock() + assert adapter.list_repo_open_issues("fake-org", "fake-repo", limit=0) == [] + adapter._paginate.assert_not_called() + + # Verify mock returning None or yielding None propagates None + adapter._paginate = MagicMock(return_value=None) + assert adapter.list_repo_open_issues("fake-org", "fake-repo") is None + adapter._paginate = MagicMock(return_value=[None]) + assert adapter.list_repo_open_issues("fake-org", "fake-repo") is None + + assert adapter._client.is_closed + + # Verify request failure (None from _request) propagates None + with GitHubRestAdapter("fake-token", "fake-org", "https://api.github.com") as real_paginate_adapter: + real_paginate_adapter._request = MagicMock(return_value=None) + assert real_paginate_adapter.list_repo_open_issues("fake-org", "nonexistent-repo") is None + + # Verify non-200 response (e.g. 500 server error) propagates None + error_response = MagicMock(status_code=500) + real_paginate_adapter._request = MagicMock(return_value=error_response) + assert real_paginate_adapter.list_repo_open_issues("fake-org", "server-error-repo") is None + + # Verify 200 OK with empty issues list returns empty list [] (not None) + ok_empty_response = MagicMock(status_code=200) + ok_empty_response.json.return_value = [] + ok_empty_response.headers = {} + real_paginate_adapter._request = MagicMock(return_value=ok_empty_response) + assert real_paginate_adapter.list_repo_open_issues("fake-org", "empty-repo") == [] + + assert real_paginate_adapter._client.is_closed + + +def test_issue_handling_fetch_error_vs_empty(): + import asyncio + from unittest.mock import AsyncMock + + async def _handle(raw_issues: list[dict] | None) -> list[dict]: + followup = AsyncMock() + if raw_issues is None: + await followup.send( + "❌ Error fetching issues. Please try again later.", + ephemeral=True, + ) + else: + issues = filter_open_issues(raw_issues, limit=10) + messages = format_issue_list_messages( + issues=issues, + org="fake-org", + repo="fake-repo", + limit=10, + ) + for message in messages: + await followup.send( + message, + ephemeral=True, + suppress_embeds=True, + ) + return followup.send.call_args_list + + # Error case (raw_issues is None) + calls_err = asyncio.run(_handle(None)) + assert len(calls_err) == 1 + assert "❌ Error fetching issues. Please try again later." in calls_err[0].args[0] + + # Empty case (raw_issues is []) + calls_empty = asyncio.run(_handle([])) + assert len(calls_empty) == 1 + assert "No open issues found in **fake-repo**." in calls_empty[0].args[0] + - assert len(issues) == 2 - assert [i["number"] for i in issues] == [1, 3] diff --git a/tests/test_pr_status.py b/tests/test_pr_status.py index b4b50119..b58239dc 100644 --- a/tests/test_pr_status.py +++ b/tests/test_pr_status.py @@ -1207,19 +1207,25 @@ def test_pr_status_gated_when_configured_in_command_permissions(self) -> None: class TestRepoRecommendationAndAutocomplete: def test_cfg_get_helper_behavior(self) -> None: - """_cfg_get handles dicts, objects, None, missing keys, and defaults.""" + """cfg_get handles dicts, objects, None, missing keys, and defaults.""" + from ghdcbot.config import cfg_get as cfg_get_pkg + from ghdcbot.config.access import cfg_get from ghdcbot.engine.pr_status import _cfg_get + assert _cfg_get is cfg_get + assert cfg_get_pkg is cfg_get + # None target returns default - assert _cfg_get(None, "key") is None + assert cfg_get(None, "key") is None + assert cfg_get(None, "key", "default_val") == "default_val" assert _cfg_get(None, "key", "default_val") == "default_val" # Dict lookups d = {"existing": "val", "nullable": None, "empty_str": ""} - assert _cfg_get(d, "existing") == "val" - assert _cfg_get(d, "missing", "fallback") == "fallback" - assert _cfg_get(d, "nullable", "fallback") == "fallback" - assert _cfg_get(d, "empty_str", "fallback") == "" + assert cfg_get(d, "existing") == "val" + assert cfg_get(d, "missing", "fallback") == "fallback" + assert cfg_get(d, "nullable", "fallback") == "fallback" + assert cfg_get(d, "empty_str", "fallback") == "" # Object attribute lookups class Dummy: @@ -1228,10 +1234,10 @@ class Dummy: empty_str = "" obj = Dummy() - assert _cfg_get(obj, "existing") == "attr_val" - assert _cfg_get(obj, "missing", "fallback") == "fallback" - assert _cfg_get(obj, "nullable", "fallback") == "fallback" - assert _cfg_get(obj, "empty_str", "fallback") == "" + assert cfg_get(obj, "existing") == "attr_val" + assert cfg_get(obj, "missing", "fallback") == "fallback" + assert cfg_get(obj, "nullable", "fallback") == "fallback" + assert cfg_get(obj, "empty_str", "fallback") == "" def test_get_configured_repo_names_allow_mode(self) -> None: """Configured repos with mode='allow' are returned in order.""" From cb86720be6314c019338a8c6fd2a8f4dba69e9c8 Mon Sep 17 00:00:00 2001 From: PrithvijitBose Date: Fri, 11 Sep 2026 19:54:21 +0530 Subject: [PATCH 3/6] feat: add optional repo parameter and autocomplete to /issue command --- QUICK_START_GUIDE.txt | 2 +- README.md | 2 +- src/ghdcbot/bot.py | 19 +++- src/ghdcbot/engine/issue_list.py | 2 +- tests/test_issue_list.py | 190 +++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 4 deletions(-) diff --git a/QUICK_START_GUIDE.txt b/QUICK_START_GUIDE.txt index 0485b5c2..def8ab29 100644 --- a/QUICK_START_GUIDE.txt +++ b/QUICK_START_GUIDE.txt @@ -34,7 +34,7 @@ Tips: /summary Contribution metrics (7d / 30d); optional member /open-prs List currently open PRs for a contributor /pr Recent PRs by closed / merged / open (count, skip) -/issue Recent open issues in project channel (excluding PRs; limit) +/issue Recent open issues in project channel or repo (limit, repo) /connect-social Attach X or LinkedIn username/URL to your profile /disconnect-social Remove X or LinkedIn from your profile /unlink Remove your GitHub link diff --git a/README.md b/README.md index 51e8ca85..e63efa39 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ Contributor-facing cheat sheet: [`QUICK_START_GUIDE.txt`](QUICK_START_GUIDE.txt) - `/summary` - Show contribution metrics (7 and 30 days); optional Discord member for another verified contributor - `/open-prs` - List a contributor's currently open PRs in configured repos - `/pr` - List a contributor's recent PRs grouped by closed / merged / open (`count` N, optional `skip` M) -- `/issue` - List recent open issues in the project channel (excluding PRs) with configurable `limit` (default 10, max 50) +- `/issue` - List recent open issues in the project channel or specified repository (excluding PRs) with optional `repo` (with autocomplete) and `limit` (default 10, max 50) - `/pr-status` - Show PR health (CI, CodeRabbit review threads, merge conflicts, approval state) for a single PR or multi-PR org dashboard (`show_all:True`, optional `skip` M). Accessible to all server members by default (cooldown: one invocation per user every 5 seconds); optionally gateable via `discord.command_permissions.pr-status`. Dashboard requests are capped at 25 PRs per page. Each PR uses a small number of REST calls (pull request, reviews, check runs) plus paginated GraphQL queries for review threads, so a full dashboard page costs roughly 100 or more API requests. When querying a single PR, omitting `repo:` probes each configured repository with one additional REST call to auto-detect the repository (capped at 25 candidate repositories, resulting in a maximum of 25 probe calls). ### Sync (mentor-only) diff --git a/src/ghdcbot/bot.py b/src/ghdcbot/bot.py index e09d1942..0ff59c7f 100644 --- a/src/ghdcbot/bot.py +++ b/src/ghdcbot/bot.py @@ -975,15 +975,17 @@ async def pr_list_repo_autocomplete( @tree.command( name="issue", - description="List recent open issues in the project channel (excluding PRs)", + description="List recent open issues in the project channel or specified repository (excluding PRs)", guild=discord.Object(id=guild_id), ) @app_commands.describe( + repo="Repository name (optional; auto-detected from channel or config if omitted)", limit="How many recent open issues to show (optional, default 10, max 50)", ) @app_commands.checks.cooldown(1, 15.0) async def issue_cmd( interaction: discord.Interaction, + repo: str | None = None, limit: app_commands.Range[int, 1, 50] = 10, ) -> None: await interaction.response.defer(ephemeral=True) @@ -994,6 +996,7 @@ async def issue_cmd( config=config, channel_id=channel_id, channel_name=channel_name, + repo=repo, ) if error_msg or not resolved_repo: await interaction.followup.send( @@ -1060,6 +1063,20 @@ async def issue_cmd( suppress_embeds=True, ) + @issue_cmd.autocomplete("repo") + async def issue_repo_autocomplete( + interaction: discord.Interaction, + current: str, + ) -> list[app_commands.Choice[str]]: + configured_repos = get_configured_repo_names(config) + suggestions = filter_repo_suggestions(configured_repos, current) + logger.debug( + "Autocomplete for issue repo: current=%r, suggestions=%s", + current, + suggestions, + ) + return [app_commands.Choice(name=r, value=r) for r in suggestions] + @tree.command( name="who-is", description="Lookup a GitHub username to find their verified Discord account", diff --git a/src/ghdcbot/engine/issue_list.py b/src/ghdcbot/engine/issue_list.py index 8f887055..0ed15181 100644 --- a/src/ghdcbot/engine/issue_list.py +++ b/src/ghdcbot/engine/issue_list.py @@ -258,4 +258,4 @@ def resolve_repo_for_issue( if not configured_repos: return None, "❌ No repositories configured in Gitcord." - return None, "❌ Could not auto-detect repository from this channel. Please run this command in a project channel." + return None, "❌ Could not auto-detect repository from this channel. Please run this command in a project channel or specify repo." diff --git a/tests/test_issue_list.py b/tests/test_issue_list.py index 3ad236e2..6eaaebb1 100644 --- a/tests/test_issue_list.py +++ b/tests/test_issue_list.py @@ -148,6 +148,28 @@ def test_resolve_repo_for_issue(): assert repo == "Knowledge-Agent" assert err is None + # Multi-repo config + config_multi = MagicMock() + config_multi.github.repos.mode = "allow" + config_multi.github.repos.names = ["Knowledge-Agent", "Devr.AI"] + config_multi.discord.pr_open_channels = {"Knowledge-Agent": "11223344"} + config_multi.repo_contributor_roles = {} + + # Multi-repo: explicit allowed repo chosen + repo, err = resolve_repo_for_issue(config_multi, repo="Devr.AI") + assert repo == "Devr.AI" + assert err is None + + # Multi-repo: explicit repo with whitespace + repo, err = resolve_repo_for_issue(config_multi, repo=" Devr.AI ") + assert repo == "Devr.AI" + assert err is None + + # Multi-repo: cannot auto-detect, prompts to specify repo + repo, err = resolve_repo_for_issue(config_multi, channel_id="99999999", channel_name="general") + assert repo is None + assert "specify repo" in err + def test_github_rest_adapter_list_repo_open_issues(): from ghdcbot.adapters.github.rest import GitHubRestAdapter @@ -260,4 +282,172 @@ async def _handle(raw_issues: list[dict] | None) -> list[dict]: assert "No open issues found in **fake-repo**." in calls_empty[0].args[0] +def test_issue_repo_autocomplete_choice_creation() -> None: + """Issue repo autocomplete choices filter and map correctly.""" + from discord import app_commands + + from ghdcbot.engine.pr_status import filter_repo_suggestions + + repos = ["Knowledge-Agent", "Devr.AI", "Gitcord"] + suggestions = filter_repo_suggestions(repos, "know") + choices = [app_commands.Choice(name=r, value=r) for r in suggestions] + assert len(choices) == 1 + assert choices[0].name == "Knowledge-Agent" + assert choices[0].value == "Knowledge-Agent" + + +def test_issue_repo_autocomplete_integration() -> None: + """In run_bot, /issue command has repo param with autocomplete connected to config.""" + import asyncio + from typing import Any + from unittest.mock import MagicMock, patch + + import discord + + from ghdcbot.bot import run_bot + from ghdcbot.config.models import ( + BotConfig, + DiscordConfig, + GitHubConfig, + RepoFilterConfig, + RuntimeConfig, + ) + + cfg = BotConfig( + runtime=RuntimeConfig( + data_dir="./data", + github_adapter="ghdcbot.adapters.github.rest:GitHubRestAdapter", + discord_adapter="ghdcbot.adapters.discord.api:DiscordApiAdapter", + storage_adapter="ghdcbot.adapters.storage.sqlite:SqliteStorage", + ), + github=GitHubConfig( + org="test-org", + repos=RepoFilterConfig(mode="allow", names=["Knowledge-Agent", "Devr.AI"]), + ), + discord=DiscordConfig(guild_id="123", token="fake"), + ) + + captured = [] + orig_tree_init = discord.app_commands.CommandTree.__init__ + + def mock_tree_init(tree_self: Any, client: Any) -> None: + captured.append(tree_self) + orig_tree_init(tree_self, client) + + with ( + patch("ghdcbot.bot.load_config", return_value=cfg), + patch("ghdcbot.bot.resolve_github_token", return_value="fake"), + patch("ghdcbot.bot.build_adapter"), + patch("ghdcbot.bot.GitHubIdentityReader"), + patch("ghdcbot.bot.IdentityLinkService"), + patch("ghdcbot.bot.SocialProfileService"), + patch("discord.app_commands.CommandTree.__init__", mock_tree_init), + patch("discord.Client.run", side_effect=SystemExit(0)), + ): + try: + run_bot("dummy.yaml") + except SystemExit: + pass + + assert len(captured) == 1 + tree = captured[0] + issue_cmds = [ + cmd + for cmd in tree.get_commands(guild=discord.Object(id=123)) + if cmd.name == "issue" + ] + assert len(issue_cmds) == 1 + issue = issue_cmds[0] + + # Verify repo parameter exists and has autocomplete registered + repo_params = [p for p in issue.parameters if p.name == "repo"] + assert len(repo_params) == 1 + assert repo_params[0].autocomplete is True + + # Call the autocomplete callback and verify suggestions from config + callback = issue._params["repo"].autocomplete + mock_interaction = MagicMock() + choices = asyncio.run(callback(mock_interaction, "dev")) + assert len(choices) == 1 + assert choices[0].name == "Devr.AI" + assert choices[0].value == "Devr.AI" + + +def test_issue_cmd_with_explicit_repo() -> None: + """Calling /issue with an explicit repo queries that repo.""" + import asyncio + from typing import Any + from unittest.mock import AsyncMock, MagicMock, patch + + import discord + + from ghdcbot.bot import run_bot + from ghdcbot.config.models import ( + BotConfig, + DiscordConfig, + GitHubConfig, + RepoFilterConfig, + RuntimeConfig, + ) + + cfg = BotConfig( + runtime=RuntimeConfig( + data_dir="./data", + github_adapter="ghdcbot.adapters.github.rest:GitHubRestAdapter", + discord_adapter="ghdcbot.adapters.discord.api:DiscordApiAdapter", + storage_adapter="ghdcbot.adapters.storage.sqlite:SqliteStorage", + ), + github=GitHubConfig( + org="test-org", + repos=RepoFilterConfig(mode="allow", names=["Knowledge-Agent", "Devr.AI"]), + ), + discord=DiscordConfig(guild_id="123", token="fake"), + ) + + captured = [] + orig_tree_init = discord.app_commands.CommandTree.__init__ + + def mock_tree_init(tree_self: Any, client: Any) -> None: + captured.append(tree_self) + orig_tree_init(tree_self, client) + + mock_gh = MagicMock() + mock_gh.list_repo_open_issues.return_value = [ + {"number": 101, "title": "Devr issue", "state": "open"} + ] + + with ( + patch("ghdcbot.bot.load_config", return_value=cfg), + patch("ghdcbot.bot.resolve_github_token", return_value="fake"), + patch("ghdcbot.bot.build_adapter", return_value=mock_gh), + patch("ghdcbot.bot.GitHubIdentityReader"), + patch("ghdcbot.bot.IdentityLinkService"), + patch("ghdcbot.bot.SocialProfileService"), + patch("discord.app_commands.CommandTree.__init__", mock_tree_init), + patch("discord.Client.run", side_effect=SystemExit(0)), + ): + try: + run_bot("dummy.yaml") + except SystemExit: + pass + + tree = captured[0] + issue_cmd = next(c for c in tree.get_commands(guild=discord.Object(id=123)) if c.name == "issue") + + mock_interaction = MagicMock() + mock_interaction.response.defer = AsyncMock() + mock_interaction.followup.send = AsyncMock() + mock_interaction.channel_id = 99999 + mock_interaction.channel.name = "general" + mock_interaction.user.id = 456 + + asyncio.run(issue_cmd.callback(mock_interaction, repo="Devr.AI", limit=5)) + + mock_gh.list_repo_open_issues.assert_called_once_with("test-org", "Devr.AI", 5) + mock_interaction.followup.send.assert_called_once() + sent_text = mock_interaction.followup.send.call_args[0][0] + assert "Devr.AI" in sent_text + assert "#101" in sent_text + + From 90f37bff546d5db3a23b3a017bf5aa5d463eeb37 Mon Sep 17 00:00:00 2001 From: PrithvijitBose Date: Fri, 11 Sep 2026 20:22:07 +0530 Subject: [PATCH 4/6] fix: make pagination error call-local and exercise real command in tests --- src/ghdcbot/adapters/github/rest.py | 70 +++++++--- src/ghdcbot/bot.py | 18 ++- tests/test_issue_list.py | 208 ++++++++++++++++++++-------- 3 files changed, 215 insertions(+), 81 deletions(-) diff --git a/src/ghdcbot/adapters/github/rest.py b/src/ghdcbot/adapters/github/rest.py index 8c63d16a..3a0687e3 100644 --- a/src/ghdcbot/adapters/github/rest.py +++ b/src/ghdcbot/adapters/github/rest.py @@ -103,6 +103,10 @@ def _append_repo_search_qualifiers( return query +class GitHubPaginationError(RuntimeError): + """Raised when GitHub pagination encounters an HTTP or network error.""" + + class GitHubRestAdapter: def __init__(self, token: str | Callable[[], str], org: str, api_base: str) -> None: self._logger = logging.getLogger(self.__class__.__name__) @@ -113,7 +117,6 @@ def __init__(self, token: str | Callable[[], str], org: str, api_base: str) -> N self._sync_cached_repos: list[dict] | None = None self._sync_request_count = 0 self._sync_repos_processed = 0 - self._last_paginate_error = False self._client = build_github_httpx_client(token, api_base=api_base, timeout=30.0) def close(self) -> None: @@ -644,20 +647,24 @@ def list_repo_open_issues( issues: list[dict] = [] page_size = max(1, min(per_page, 100)) params = {"state": "open", "sort": "created", "direction": "desc", "per_page": page_size} - pages = self._paginate(f"/repos/{owner}/{repo}/issues", params=params) - if pages is None: - return None - for page in pages: - if page is None: + try: + pages = self._paginate( + f"/repos/{owner}/{repo}/issues", + params=params, + raise_on_error=True, + ) + if pages is None: return None - for item in page: - if "pull_request" in item: - continue - issues.append(item) - if len(issues) >= limit: - return issues - - if getattr(self, "_last_paginate_error", False): + for page in pages: + if page is None: + return None + for item in page: + if "pull_request" in item: + continue + issues.append(item) + if len(issues) >= limit: + return issues + except GitHubPaginationError: return None return issues @@ -1814,16 +1821,25 @@ def _list_repo_open_prs(self, repo: dict) -> Iterable[dict]: "created_at": pr.get("created_at"), } - def _paginate(self, path: str, params: dict) -> Iterator[list]: - self._last_paginate_error = False + def _paginate( + self, + path: str, + params: dict, + *, + raise_on_error: bool = False, + ) -> Iterator[list]: page = 1 while True: response = self._request("GET", path, params={**params, "page": page}) if response is None: - self._last_paginate_error = True + if raise_on_error: + raise GitHubPaginationError(f"GitHub request failed: {path}") return if response.status_code != 200: - self._last_paginate_error = True + if raise_on_error: + raise GitHubPaginationError( + f"GitHub request failed with status {response.status_code}: {path}" + ) self._logger.warning( "GitHub request failed", extra={"path": path, "status_code": response.status_code}, @@ -1853,16 +1869,26 @@ def _list_repos_from_path(self, path: str) -> tuple[list[dict], int | None]: repos.extend(page) return repos, response.status_code - def _paginate_from_page(self, path: str, params: dict, start_page: int) -> Iterator[list]: - self._last_paginate_error = False + def _paginate_from_page( + self, + path: str, + params: dict, + start_page: int, + *, + raise_on_error: bool = False, + ) -> Iterator[list]: page = start_page while True: response = self._request("GET", path, params={**params, "page": page}) if response is None: - self._last_paginate_error = True + if raise_on_error: + raise GitHubPaginationError(f"GitHub request failed: {path}") return if response.status_code != 200: - self._last_paginate_error = True + if raise_on_error: + raise GitHubPaginationError( + f"GitHub request failed with status {response.status_code}: {path}" + ) self._logger.warning( "GitHub request failed", extra={"path": path, "status_code": response.status_code}, diff --git a/src/ghdcbot/bot.py b/src/ghdcbot/bot.py index 0ff59c7f..00f0e4b2 100644 --- a/src/ghdcbot/bot.py +++ b/src/ghdcbot/bot.py @@ -397,6 +397,15 @@ async def handle_app_command_error( logger.error("Could not send error message to user") +def get_issue_repo_choices( + config: Any, current: str +) -> list[app_commands.Choice[str]]: + """Generate autocomplete choices for the /issue repo option from config.""" + configured_repos = get_configured_repo_names(config) + suggestions = filter_repo_suggestions(configured_repos, current) + return [app_commands.Choice(name=r, value=r) for r in suggestions] + + def run_bot(config_path: str) -> None: """Run the Discord bot with /link, /verify-link, /help-link, /profile, and /summary.""" config = load_config(config_path) @@ -1068,14 +1077,13 @@ async def issue_repo_autocomplete( interaction: discord.Interaction, current: str, ) -> list[app_commands.Choice[str]]: - configured_repos = get_configured_repo_names(config) - suggestions = filter_repo_suggestions(configured_repos, current) + choices = get_issue_repo_choices(config, current) logger.debug( - "Autocomplete for issue repo: current=%r, suggestions=%s", + "Autocomplete for issue repo: current=%r, count=%d", current, - suggestions, + len(choices), ) - return [app_commands.Choice(name=r, value=r) for r in suggestions] + return choices @tree.command( name="who-is", diff --git a/tests/test_issue_list.py b/tests/test_issue_list.py index 6eaaebb1..174555fc 100644 --- a/tests/test_issue_list.py +++ b/tests/test_issue_list.py @@ -189,6 +189,7 @@ def test_github_rest_adapter_list_repo_open_issues(): adapter._paginate.assert_called_once_with( "/repos/fake-org/fake-repo/issues", params={"state": "open", "sort": "created", "direction": "desc", "per_page": 100}, + raise_on_error=True, ) # Verify per_page specifies GitHub API page size @@ -198,6 +199,7 @@ def test_github_rest_adapter_list_repo_open_issues(): adapter._paginate.assert_called_once_with( "/repos/fake-org/fake-repo/issues", params={"state": "open", "sort": "created", "direction": "desc", "per_page": 25}, + raise_on_error=True, ) # Verify per_page > 100 is clamped and does not exceed limit @@ -209,6 +211,7 @@ def test_github_rest_adapter_list_repo_open_issues(): adapter._paginate.assert_called_once_with( "/repos/fake-org/fake-repo/issues", params={"state": "open", "sort": "created", "direction": "desc", "per_page": 100}, + raise_on_error=True, ) # Verify non-positive limit returns empty list without making API calls @@ -222,6 +225,12 @@ def test_github_rest_adapter_list_repo_open_issues(): adapter._paginate = MagicMock(return_value=[None]) assert adapter.list_repo_open_issues("fake-org", "fake-repo") is None + # Verify GitHubPaginationError propagates as None + from ghdcbot.adapters.github.rest import GitHubPaginationError + + adapter._paginate = MagicMock(side_effect=GitHubPaginationError("Request failed")) + assert adapter.list_repo_open_issues("fake-org", "fake-repo") is None + assert adapter._client.is_closed # Verify request failure (None from _request) propagates None @@ -244,63 +253,162 @@ def test_github_rest_adapter_list_repo_open_issues(): assert real_paginate_adapter._client.is_closed -def test_issue_handling_fetch_error_vs_empty(): +def test_list_repo_open_issues_concurrency() -> None: + """Concurrent list_repo_open_issues invocations do not corrupt each other's error state.""" + import concurrent.futures + from typing import Any + from unittest.mock import MagicMock + + from ghdcbot.adapters.github.rest import GitHubRestAdapter + + with GitHubRestAdapter("fake-token", "fake-org", "https://api.github.com") as adapter: + def fake_request(method: str, path: str, params: dict | None = None) -> Any: + if "fail-repo" in path: + return MagicMock(status_code=500) + if "success-repo" in path: + res = MagicMock(status_code=200) + res.json.return_value = [{"number": 1, "title": "Success issue", "state": "open"}] + res.headers = {} + return res + return None + + adapter._request = MagicMock(side_effect=fake_request) + + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + fut_fail = executor.submit(adapter.list_repo_open_issues, "fake-org", "fail-repo") + fut_success = executor.submit(adapter.list_repo_open_issues, "fake-org", "success-repo") + + assert fut_fail.result() is None + success_issues = fut_success.result() + assert success_issues is not None + assert len(success_issues) == 1 + assert success_issues[0]["number"] == 1 + + +def test_issue_handling_fetch_error_vs_empty() -> None: + """Exercise real issue_cmd.callback with None (fetch error) and [] (empty list).""" import asyncio - from unittest.mock import AsyncMock - - async def _handle(raw_issues: list[dict] | None) -> list[dict]: - followup = AsyncMock() - if raw_issues is None: - await followup.send( - "❌ Error fetching issues. Please try again later.", - ephemeral=True, - ) - else: - issues = filter_open_issues(raw_issues, limit=10) - messages = format_issue_list_messages( - issues=issues, - org="fake-org", - repo="fake-repo", - limit=10, - ) - for message in messages: - await followup.send( - message, - ephemeral=True, - suppress_embeds=True, - ) - return followup.send.call_args_list - - # Error case (raw_issues is None) - calls_err = asyncio.run(_handle(None)) - assert len(calls_err) == 1 - assert "❌ Error fetching issues. Please try again later." in calls_err[0].args[0] - - # Empty case (raw_issues is []) - calls_empty = asyncio.run(_handle([])) - assert len(calls_empty) == 1 - assert "No open issues found in **fake-repo**." in calls_empty[0].args[0] + from typing import Any + from unittest.mock import AsyncMock, MagicMock, patch + + import discord + + from ghdcbot.bot import run_bot + from ghdcbot.config.models import ( + BotConfig, + DiscordConfig, + GitHubConfig, + RepoFilterConfig, + RuntimeConfig, + ) + + cfg = BotConfig( + runtime=RuntimeConfig( + data_dir="./data", + github_adapter="ghdcbot.adapters.github.rest:GitHubRestAdapter", + discord_adapter="ghdcbot.adapters.discord.api:DiscordApiAdapter", + storage_adapter="ghdcbot.adapters.storage.sqlite:SqliteStorage", + ), + github=GitHubConfig( + org="test-org", + repos=RepoFilterConfig(mode="allow", names=["Knowledge-Agent", "Devr.AI"]), + ), + discord=DiscordConfig(guild_id="123", token="fake"), + ) + + captured = [] + orig_tree_init = discord.app_commands.CommandTree.__init__ + + def mock_tree_init(tree_self: Any, client: Any) -> None: + captured.append(tree_self) + orig_tree_init(tree_self, client) + + mock_gh = MagicMock() + + with ( + patch("ghdcbot.bot.load_config", return_value=cfg), + patch("ghdcbot.bot.resolve_github_token", return_value="fake"), + patch("ghdcbot.bot.build_adapter", return_value=mock_gh), + patch("ghdcbot.bot.GitHubIdentityReader"), + patch("ghdcbot.bot.IdentityLinkService"), + patch("ghdcbot.bot.SocialProfileService"), + patch("discord.app_commands.CommandTree.__init__", mock_tree_init), + patch("discord.Client.run", side_effect=SystemExit(0)), + ): + try: + run_bot("dummy.yaml") + except SystemExit: + pass + + tree = captured[0] + issue_cmd = next(c for c in tree.get_commands(guild=discord.Object(id=123)) if c.name == "issue") + + # 1. Error case (list_repo_open_issues returns None) + mock_gh.list_repo_open_issues.return_value = None + mock_interaction = MagicMock() + mock_interaction.response.defer = AsyncMock() + mock_interaction.followup.send = AsyncMock() + mock_interaction.channel_id = 99999 + mock_interaction.channel.name = "general" + mock_interaction.user.id = 456 + + asyncio.run(issue_cmd.callback(mock_interaction, repo="Devr.AI", limit=5)) + + mock_interaction.followup.send.assert_called_once() + err_text = mock_interaction.followup.send.call_args[0][0] + assert "❌ Error fetching issues. Please try again later." in err_text + + # 2. Empty case (list_repo_open_issues returns []) + mock_gh.list_repo_open_issues.return_value = [] + mock_interaction.followup.send.reset_mock() + + asyncio.run(issue_cmd.callback(mock_interaction, repo="Devr.AI", limit=5)) + + mock_interaction.followup.send.assert_called_once() + empty_text = mock_interaction.followup.send.call_args[0][0] + assert "No open issues found in **Devr.AI**." in empty_text def test_issue_repo_autocomplete_choice_creation() -> None: - """Issue repo autocomplete choices filter and map correctly.""" - from discord import app_commands + """Issue repo autocomplete choices filter and map correctly via get_issue_repo_choices.""" + from ghdcbot.bot import get_issue_repo_choices + from ghdcbot.config.models import ( + BotConfig, + DiscordConfig, + GitHubConfig, + RepoFilterConfig, + RuntimeConfig, + ) - from ghdcbot.engine.pr_status import filter_repo_suggestions + cfg = BotConfig( + runtime=RuntimeConfig( + data_dir="./data", + github_adapter="ghdcbot.adapters.github.rest:GitHubRestAdapter", + discord_adapter="ghdcbot.adapters.discord.api:DiscordApiAdapter", + storage_adapter="ghdcbot.adapters.storage.sqlite:SqliteStorage", + ), + github=GitHubConfig( + org="test-org", + repos=RepoFilterConfig(mode="allow", names=["Knowledge-Agent", "Devr.AI"]), + ), + discord=DiscordConfig(guild_id="123", token="fake"), + ) - repos = ["Knowledge-Agent", "Devr.AI", "Gitcord"] - suggestions = filter_repo_suggestions(repos, "know") - choices = [app_commands.Choice(name=r, value=r) for r in suggestions] + choices = get_issue_repo_choices(cfg, "dev") assert len(choices) == 1 - assert choices[0].name == "Knowledge-Agent" - assert choices[0].value == "Knowledge-Agent" + assert choices[0].name == "Devr.AI" + assert choices[0].value == "Devr.AI" + + # Multiple match + all_choices = get_issue_repo_choices(cfg, "") + assert len(all_choices) == 2 + assert [c.name for c in all_choices] == ["Knowledge-Agent", "Devr.AI"] def test_issue_repo_autocomplete_integration() -> None: """In run_bot, /issue command has repo param with autocomplete connected to config.""" - import asyncio from typing import Any - from unittest.mock import MagicMock, patch + from unittest.mock import patch import discord @@ -359,19 +467,11 @@ def mock_tree_init(tree_self: Any, client: Any) -> None: assert len(issue_cmds) == 1 issue = issue_cmds[0] - # Verify repo parameter exists and has autocomplete registered + # Verify repo parameter exists and has autocomplete registered using public API repo_params = [p for p in issue.parameters if p.name == "repo"] assert len(repo_params) == 1 assert repo_params[0].autocomplete is True - # Call the autocomplete callback and verify suggestions from config - callback = issue._params["repo"].autocomplete - mock_interaction = MagicMock() - choices = asyncio.run(callback(mock_interaction, "dev")) - assert len(choices) == 1 - assert choices[0].name == "Devr.AI" - assert choices[0].value == "Devr.AI" - def test_issue_cmd_with_explicit_repo() -> None: """Calling /issue with an explicit repo queries that repo.""" From deba2dd1e91388c57526a07941b705549cc92a68 Mon Sep 17 00:00:00 2001 From: PrithvijitBose Date: Mon, 14 Sep 2026 09:04:04 +0530 Subject: [PATCH 5/6] refactor: remove description snippet from /issue command output --- src/ghdcbot/engine/issue_list.py | 28 +--------------------------- tests/test_issue_list.py | 21 ++------------------- 2 files changed, 3 insertions(+), 46 deletions(-) diff --git a/src/ghdcbot/engine/issue_list.py b/src/ghdcbot/engine/issue_list.py index 0ed15181..4468bc28 100644 --- a/src/ghdcbot/engine/issue_list.py +++ b/src/ghdcbot/engine/issue_list.py @@ -2,7 +2,6 @@ from __future__ import annotations -import re from collections.abc import Iterable, Sequence from typing import Any @@ -42,29 +41,6 @@ def filter_open_issues(items: Iterable[dict], limit: int = 10) -> list[dict]: return filtered -def format_issue_description(body: str | None, max_length: int = 180) -> str: - """Format issue description into a clean, compact blockquote snippet.""" - if not body or not body.strip(): - return "> _No description provided._" - - text = body.strip() - # Strip HTML comments - text = re.sub(r"", "", text, flags=re.DOTALL) - # Strip image markdown ![...](...) - text = re.sub(r"!\[.*?\]\(.*?\)", "", text) - # Collapse consecutive whitespace and newlines - lines = [line.strip() for line in text.splitlines() if line.strip()] - cleaned = " ".join(lines).strip() - - if not cleaned: - return "> _No description provided._" - - if len(cleaned) > max_length: - cleaned = cleaned[: max_length - 3].rstrip() + "..." - - return f"> {cleaned}" - - def _suppress_discord_embed(url: str) -> str: """Wrap URL in <> so Discord does not render a link preview embed.""" text = (url or "").strip() @@ -89,7 +65,6 @@ def format_single_issue_entry( - Who opened it (resolving Discord ID if linked) - Comments count - Labels (if any) - - Description snippet """ number = issue.get("number", "?") title = (issue.get("title") or "No title").strip() @@ -127,9 +102,8 @@ def format_single_issue_entry( title_line = f"• [#{number}]({suppressed_url}) — **{title}**" meta_line = " " + " • ".join(meta_parts) - desc_line = f" {format_issue_description(issue.get('body'))}" - return [title_line, meta_line, desc_line] + return [title_line, meta_line] def _chunk_message_lines(lines: Sequence[str], *, max_chars: int = _MAX_MESSAGE_CHARS) -> list[str]: diff --git a/tests/test_issue_list.py b/tests/test_issue_list.py index 174555fc..00aca877 100644 --- a/tests/test_issue_list.py +++ b/tests/test_issue_list.py @@ -7,7 +7,6 @@ from ghdcbot.engine.issue_list import ( clamp_issue_limit, filter_open_issues, - format_issue_description, format_issue_list_messages, format_single_issue_entry, resolve_repo_for_issue, @@ -36,21 +35,6 @@ def test_filter_open_issues_excludes_prs_and_closed(): assert [i["number"] for i in filtered] == [1, 4] -def test_format_issue_description(): - assert format_issue_description(None) == "> _No description provided._" - assert format_issue_description("") == "> _No description provided._" - assert format_issue_description(" \n\n ") == "> _No description provided._" - - body_with_html_comment = "This is the actual issue content." - assert format_issue_description(body_with_html_comment) == "> This is the actual issue content." - - long_body = "A" * 300 - formatted = format_issue_description(long_body, max_length=50) - assert len(formatted) <= 55 - assert formatted.endswith("...") - assert formatted.startswith("> ") - - def test_format_single_issue_entry_with_author_and_labels(): issue = { "number": 42, @@ -63,13 +47,12 @@ def test_format_single_issue_entry_with_author_and_labels(): "body": "Please add dark mode.", } lines = format_single_issue_entry(issue, org="org", repo="repo", storage=None) - assert len(lines) == 3 + assert len(lines) == 2 assert lines[0] == "• [#42]() — **Add dark mode toggle**" assert "Status: `Open 🟢`" in lines[1] assert "Opened by: octocat" in lines[1] assert "💬 3 comments" in lines[1] assert "🏷️ enhancement, ui" in lines[1] - assert lines[2] == " > Please add dark mode." def test_format_single_issue_entry_with_discord_link(): @@ -90,10 +73,10 @@ def test_format_single_issue_entry_with_discord_link(): "body": None, } lines = format_single_issue_entry(issue, org="org", repo="repo", storage=storage) + assert len(lines) == 2 assert "Opened by: <@123456789> (alice)" in lines[1] assert "💬 1 comment" in lines[1] assert "🏷️" not in lines[1] - assert lines[2] == " > _No description provided._" def test_format_issue_list_messages_chunking(): From 8bea33eb465893bbb8791c3e71e25b2c0a93e703 Mon Sep 17 00:00:00 2001 From: PrithvijitBose Date: Mon, 14 Sep 2026 10:27:35 +0530 Subject: [PATCH 6/6] fix(bot): filter /issue repo autocomplete by configured github.repos policy - Enforce repository allowlist in get_issue_repo_choices before returning autocomplete options. - Add test_issue_repo_autocomplete_filters_disallowed_repos to verify disallowed repositories from channel/role configs are excluded. --- src/ghdcbot/bot.py | 17 +++++++++++++++-- tests/test_issue_list.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/ghdcbot/bot.py b/src/ghdcbot/bot.py index 00f0e4b2..d30d6adc 100644 --- a/src/ghdcbot/bot.py +++ b/src/ghdcbot/bot.py @@ -13,6 +13,7 @@ from ghdcbot.adapters.discord.social_commands import register_social_commands from ghdcbot.adapters.github.app_auth import resolve_github_token from ghdcbot.adapters.github.identity import GitHubIdentityReader +from ghdcbot.config.access import cfg_get from ghdcbot.config.loader import load_config from ghdcbot.core.errors import ConfigError from ghdcbot.discord_command_permissions import ( @@ -401,9 +402,21 @@ def get_issue_repo_choices( config: Any, current: str ) -> list[app_commands.Choice[str]]: """Generate autocomplete choices for the /issue repo option from config.""" - configured_repos = get_configured_repo_names(config) + repo_filter = None + if config: + github_cfg = cfg_get(config, "github") + if github_cfg: + repo_filter = cfg_get(github_cfg, "repos") + + configured_repos = [ + r for r in get_configured_repo_names(config) if is_repo_allowed(repo_filter, r) + ] suggestions = filter_repo_suggestions(configured_repos, current) - return [app_commands.Choice(name=r, value=r) for r in suggestions] + return [ + app_commands.Choice(name=r, value=r) + for r in suggestions + if is_repo_allowed(repo_filter, r) + ] def run_bot(config_path: str) -> None: diff --git a/tests/test_issue_list.py b/tests/test_issue_list.py index 00aca877..caf8f207 100644 --- a/tests/test_issue_list.py +++ b/tests/test_issue_list.py @@ -388,6 +388,42 @@ def test_issue_repo_autocomplete_choice_creation() -> None: assert [c.name for c in all_choices] == ["Knowledge-Agent", "Devr.AI"] +def test_issue_repo_autocomplete_filters_disallowed_repos() -> None: + """Autocomplete choices strictly adhere to github.repos allowlist, ignoring unallowed repos in pr_open_channels or contributor roles.""" + from ghdcbot.bot import get_issue_repo_choices + from ghdcbot.config.models import ( + BotConfig, + DiscordConfig, + GitHubConfig, + RepoFilterConfig, + RuntimeConfig, + ) + + cfg = BotConfig( + runtime=RuntimeConfig( + data_dir="./data", + github_adapter="ghdcbot.adapters.github.rest:GitHubRestAdapter", + discord_adapter="ghdcbot.adapters.discord.api:DiscordApiAdapter", + storage_adapter="ghdcbot.adapters.storage.sqlite:SqliteStorage", + ), + github=GitHubConfig( + org="test-org", + repos=RepoFilterConfig(mode="allow", names=["Knowledge-Agent"]), + ), + discord=DiscordConfig( + guild_id="123", + token="fake", + pr_open_channels={"Disallowed-Channel-Repo": "9999"}, + ), + repo_contributor_roles={"Disallowed-Role-Repo": "Role"}, + ) + + choices = get_issue_repo_choices(cfg, "") + assert len(choices) == 1 + assert choices[0].name == "Knowledge-Agent" + assert choices[0].value == "Knowledge-Agent" + + def test_issue_repo_autocomplete_integration() -> None: """In run_bot, /issue command has repo param with autocomplete connected to config.""" from typing import Any