diff --git a/QUICK_START_GUIDE.txt b/QUICK_START_GUIDE.txt index 7ef9a951..def8ab29 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 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 0c79f98e..e63efa39 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 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/adapters/github/rest.py b/src/ghdcbot/adapters/github/rest.py index 541abe79..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__) @@ -622,6 +626,49 @@ def delete_file( ) return False + 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. + + :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] = [] + page_size = max(1, min(per_page, 100)) + params = {"state": "open", "sort": "created", "direction": "desc", "per_page": page_size} + try: + pages = self._paginate( + f"/repos/{owner}/{repo}/issues", + params=params, + raise_on_error=True, + ) + 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) >= limit: + return issues + except GitHubPaginationError: + return None + + return issues + def get_pull_request(self, owner: str, repo: str, pr_number: int) -> dict | None: """Fetch a single pull request by number. @@ -1774,13 +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]: + 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: + if raise_on_error: + raise GitHubPaginationError(f"GitHub request failed: {path}") return if response.status_code != 200: + 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}, @@ -1810,13 +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]: + 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: + if raise_on_error: + raise GitHubPaginationError(f"GitHub request failed: {path}") return if response.status_code != 200: + 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 d9a72ff3..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 ( @@ -24,6 +25,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, @@ -391,6 +398,27 @@ 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.""" + 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 + if is_repo_allowed(repo_filter, r) + ] + + 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) @@ -967,6 +995,109 @@ 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 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) + + 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, + repo=repo, + ) + 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 + + 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, + 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, + ) + + @issue_cmd.autocomplete("repo") + async def issue_repo_autocomplete( + interaction: discord.Interaction, + current: str, + ) -> list[app_commands.Choice[str]]: + choices = get_issue_repo_choices(config, current) + logger.debug( + "Autocomplete for issue repo: current=%r, count=%d", + current, + len(choices), + ) + return choices + @tree.command( name="who-is", description="Lookup a GitHub username to find their verified Discord account", 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 new file mode 100644 index 00000000..4468bc28 --- /dev/null +++ b/src/ghdcbot/engine/issue_list.py @@ -0,0 +1,235 @@ +"""Helpers for /issue: list recent open issues for a repository.""" + +from __future__ import annotations + +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 ( + 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 _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) + """ + 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) + + return [title_line, meta_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 or specify repo." 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 new file mode 100644 index 00000000..caf8f207 --- /dev/null +++ b/tests/test_issue_list.py @@ -0,0 +1,572 @@ +"""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_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_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) == 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] + + +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 len(lines) == 2 + assert "Opened by: <@123456789> (alice)" in lines[1] + assert "💬 1 comment" in lines[1] + assert "🏷️" not in lines[1] + + +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 + + # 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 + + 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"}, + ] + + 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}, + raise_on_error=True, + ) + + # 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}, + raise_on_error=True, + ) + + # 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}, + raise_on_error=True, + ) + + # 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 + + # 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 + 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_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 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 via get_issue_repo_choices.""" + 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", "Devr.AI"]), + ), + discord=DiscordConfig(guild_id="123", token="fake"), + ) + + choices = get_issue_repo_choices(cfg, "dev") + assert len(choices) == 1 + 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_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 + from unittest.mock import 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 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 + + +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 + + + 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."""