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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions QUICK_START_GUIDE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
76 changes: 74 additions & 2 deletions src/ghdcbot/adapters/github/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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},
Expand Down
131 changes: 131 additions & 0 deletions src/ghdcbot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/ghdcbot/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""config package."""

from ghdcbot.config.access import cfg_get

__all__ = ["cfg_get"]
16 changes: 16 additions & 0 deletions src/ghdcbot/config/access.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading