Skip to content
Open
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
81 changes: 81 additions & 0 deletions stampbot/github_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,87 @@ def create_pr_review_comment(
)
return False

def create_issue_comment(
self,
installation_id: int,
repo_full_name: str,
issue_number: int,
message: str,
) -> bool:
"""Create a comment on an issue or pull request.

Args:
installation_id: GitHub App installation ID
repo_full_name: Repository full name (owner/repo)
issue_number: Issue or PR number
message: Comment body

Returns:
True if successful, False otherwise
"""
start_time = time.time()

with create_span(
"github.create_issue_comment",
{
"github.repo": repo_full_name,
"github.issue_number": issue_number,
"github.installation_id": installation_id,
},
) as span:
try:
client = self._get_installation_client(installation_id)
repo = client.get_repo(repo_full_name)
issue = repo.get_issue(issue_number)

issue.create_comment(message)

duration = time.time() - start_time
github_api_request_duration_seconds.labels(operation="issue_comment").observe(
duration
)
github_api_requests_total.labels(operation="issue_comment", status="success").inc()

self._update_rate_limit_metrics(client, installation_id)

add_span_attributes(span, {"github.result": "commented"})
set_span_ok(span)

logger.info(
"Posted comment on issue #%d in %s",
issue_number,
repo_full_name,
extra={
"repo": repo_full_name,
"issue_number": issue_number,
"installation_id": installation_id,
},
)
return True

except Exception as e:
duration = time.time() - start_time
github_api_request_duration_seconds.labels(operation="issue_comment").observe(
duration
)
github_api_requests_total.labels(operation="issue_comment", status="failure").inc()

set_span_error(span, e)

logger.warning(
"Failed to post comment on issue #%d in %s: %s",
issue_number,
repo_full_name,
_sanitize_error(e),
extra={
"repo": repo_full_name,
"issue_number": issue_number,
"installation_id": installation_id,
"error": _sanitize_error(e),
},
)
return False

def repo_has_label(
self,
installation_id: int,
Expand Down
165 changes: 155 additions & 10 deletions stampbot/webhook_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,12 +435,7 @@ async def _handle_pr_comment(self, payload: dict[str, Any]) -> dict[str, Any]:
"message": "Invalid repository configuration",
}

if not repo_config.chatops_enabled:
add_span_attributes(span, {"chatops.result": "disabled"})
set_span_ok(span)
return {"status": "ignored", "message": "Chatops not enabled"}

# Parse command
# Parse command first (before checking chatops_enabled, so help always works)
command_match = re.search(r"@stampbot\s+(\w+)", comment_body)
if not command_match:
chatops_commands_total.labels(command="none", status="ignored").inc()
Expand All @@ -451,6 +446,27 @@ async def _handle_pr_comment(self, payload: dict[str, Any]) -> dict[str, Any]:
command = command_match.group(1).lower()
add_span_attributes(span, {"chatops.command": command})

# Help command always works, even when chatops is disabled
if command == "help":
await self._post_help(
installation_id,
repo_full_name,
pr_number,
repo_config,
)

chatops_commands_total.labels(command="help", status="success").inc()
add_span_attributes(span, {"chatops.result": "help_posted"})
set_span_ok(span)

return {"status": "success", "message": "Help message posted"}

# Check if chatops is enabled for other commands
if not repo_config.chatops_enabled:
add_span_attributes(span, {"chatops.result": "disabled"})
set_span_ok(span)
return {"status": "ignored", "message": "Chatops not enabled"}

if command in repo_config.approve_commands + repo_config.unapprove_commands:
has_permission = await run_in_threadpool(
github_client.user_has_permission,
Expand Down Expand Up @@ -526,10 +542,24 @@ async def _handle_pr_comment(self, payload: dict[str, Any]) -> dict[str, Any]:
"message": "Approvals dismissed" if success else "Failed to dismiss approvals",
}

chatops_commands_total.labels(command="unknown", status="ignored").inc()
add_span_attributes(span, {"chatops.result": "unknown_command"})
set_span_ok(span)
return {"status": "ignored", "message": f"Unknown command: {command}"}
# Unknown command - post help with error message
# (help command is handled earlier, before chatops_enabled check)
else:
# Truncate command for safety in message
displayed_command = command[:50] + "..." if len(command) > 50 else command
await self._post_help(
installation_id,
repo_full_name,
pr_number,
repo_config,
prefix_message=f"I don't understand `@stampbot {displayed_command}`.",
)

chatops_commands_total.labels(command="unknown", status="helped").inc()
add_span_attributes(span, {"chatops.result": "unknown_command_helped"})
set_span_ok(span)

return {"status": "success", "message": "Unknown command, help posted"}

async def _get_repo_config(
self,
Expand Down Expand Up @@ -834,6 +864,121 @@ async def _dismiss_approvals(
set_span_error(span, e)
return False

def _format_help_message(self, repo_config: RepoConfig) -> str:
"""Format contextual help message based on repo configuration.

Args:
repo_config: Repository configuration

Returns:
Formatted help message string
"""
lines = ["## Stampbot Help", ""]

# ChatOps commands section
if repo_config.chatops_enabled:
lines.append("### ChatOps Commands")
lines.append("")
lines.append(
f"Requires **{repo_config.chatops_required_permission}** permission or higher."
)
lines.append("")
approve_cmds = ", ".join(f"`@stampbot {cmd}`" for cmd in repo_config.approve_commands)
unapprove_cmds = ", ".join(
f"`@stampbot {cmd}`" for cmd in repo_config.unapprove_commands
)
lines.append(f"- **Approve**: {approve_cmds}")
lines.append(f"- **Unapprove**: {unapprove_cmds}")
lines.append("- **Help**: `@stampbot help`")
else:
lines.append("### ChatOps Commands")
lines.append("")
lines.append("ChatOps commands are **disabled** in this repository.")

lines.append("")

# Label-based approval section
if repo_config.auto_approve_on_label:
lines.append("### Label-Based Auto-Approval")
lines.append("")
labels = ", ".join(f"`{label}`" for label in repo_config.approval_labels)
lines.append(f"Adding any of these labels will trigger auto-approval: {labels}")
else:
lines.append("### Label-Based Auto-Approval")
lines.append("")
lines.append("Label-based auto-approval is **disabled** in this repository.")

lines.append("")

# Effective configuration (collapsed)
lines.append("<details>")
lines.append("<summary>Effective Configuration</summary>")
lines.append("")

# Eligibility filters
if repo_config.allowed_users:
users = ", ".join(f"`{u}`" for u in repo_config.allowed_users)
lines.append(f"**Allowed Users**: {users}")
else:
lines.append("**Allowed Users**: Any")

if repo_config.allowed_teams:
teams = ", ".join(f"`{t}`" for t in repo_config.allowed_teams)
lines.append(f"**Allowed Teams**: {teams}")
else:
lines.append("**Allowed Teams**: Any")

if repo_config.required_labels:
labels = ", ".join(f"`{label}`" for label in repo_config.required_labels)
lines.append(f"**Required Labels** (PR must have one): {labels}")
else:
lines.append("**Required Labels**: None")

if repo_config.required_title_patterns:
patterns = ", ".join(f"`{p}`" for p in repo_config.required_title_patterns)
lines.append(f"**Required Title Patterns** (PR title must match one): {patterns}")
else:
lines.append("**Required Title Patterns**: None")

lines.append("")
lines.append("</details>")

return "\n".join(lines)

async def _post_help(
self,
installation_id: int,
repo_full_name: str,
issue_number: int,
repo_config: RepoConfig,
prefix_message: str | None = None,
) -> bool:
"""Post help message to an issue or PR.

Args:
installation_id: GitHub App installation ID
repo_full_name: Repository full name
issue_number: Issue or PR number
repo_config: Repository configuration
prefix_message: Optional message to prepend before help

Returns:
True if successful
"""
help_text = self._format_help_message(repo_config)
if prefix_message:
message = f"{prefix_message}\n\n{help_text}"
else:
message = help_text

return await run_in_threadpool(
github_client.create_issue_comment,
installation_id,
repo_full_name,
issue_number,
message,
)


# Global handler instance
webhook_handler = WebhookHandler()
Loading