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
2 changes: 2 additions & 0 deletions autobot-backend/llc/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .controls import router as controls_router
from .costs import router as costs_router
from .decisions import router as decisions_router
from .github_webhooks import router as github_webhooks_router
from .goals import router as goals_router
from .health import router as health_router
from .labels import router as labels_router
Expand Down Expand Up @@ -50,6 +51,7 @@
router.include_router(secrets_router)
router.include_router(sprints_router)
router.include_router(work_items_router)
router.include_router(github_webhooks_router)
router.include_router(api_keys_router)
router.include_router(agent_router)
router.include_router(agents_router)
Expand Down
245 changes: 245 additions & 0 deletions autobot-backend/llc/api/github_webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
"""LLC GitHub webhooks handler for PR auto-linking (GH#9625).

Routes:
POST /api/llc/webhooks/github — receive GitHub webhook events

Security (fail-closed):
- Requires GITHUB_WEBHOOK_SECRET environment variable (503 if not set)
- Verifies HMAC-SHA256 signature on all requests (401 if invalid)
- Validates GitHub PR URLs with strict parsing and repo matching
"""

import hashlib
import hmac
import json
import os
import re
from typing import Any, Dict, Optional
from urllib.parse import urlparse
from uuid import UUID

from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from autobot_shared.logging_manager import get_logger
from llc.deps import get_session

from ..models.enums import WorkItemStatus
from ..models.work_item import LLCWorkItem, LLCWorkItemComment

logger = get_logger(__name__)

router = APIRouter(prefix="/webhooks", tags=["llc-github-webhooks"])

_BRANCH_PATTERNS = (
r"^llc/([a-f0-9-]+)$",
r"^autobot-llc/([a-f0-9-]+)$",
r"^llc-([a-f0-9-]+)$",
)

_BODY_PATTERN = r"(?:Closes|Fixes|Resolves)\s+#llc-([a-f0-9-]+)"


def extract_work_item_id_from_branch(branch_name: str) -> Optional[str]:
"""Extract LLC work item ID from branch name patterns.

Supported patterns: ``llc/{id}``, ``autobot-llc/{id}``, ``llc-{id}``.
"""
for pattern in _BRANCH_PATTERNS:
match = re.match(pattern, branch_name, re.IGNORECASE)
if match:
return match.group(1)
return None


def extract_work_item_id_from_body(pr_body: Optional[str]) -> Optional[str]:
"""Extract LLC work item ID from a PR body.

Supported patterns: ``Closes|Fixes|Resolves #llc-{uuid}``.
"""
if not pr_body:
return None
match = re.search(_BODY_PATTERN, pr_body, re.IGNORECASE)
if match:
return match.group(1)
return None


def validate_github_pr_url(url: str, expected_repo: Optional[str] = None) -> bool:
"""Validate a GitHub PR URL with strict parsing (GH#9625 security review).

- Requires exactly ``https://github.com`` (prevents homograph/subdomain
attacks)
- Validates path format: ``/owner/repo/pull/number``
- Rejects query params and fragments
- When ``expected_repo`` is given, verifies the URL's ``owner/repo``
matches it (prevents cross-repo spoofing in webhook payloads)
"""
if not url:
return False

try:
parsed = urlparse(url)
except ValueError:
return False

if parsed.scheme != "https" or parsed.netloc != "github.com":
return False

if parsed.query or parsed.fragment:
return False

path_match = re.match(r"^/([^/]+)/([^/]+)/pull/(\d+)$", parsed.path)
if not path_match:
return False

if expected_repo:
owner, repo, _pr_num = path_match.groups()
if f"{owner}/{repo}".lower() != expected_repo.lower():
logger.warning(
"PR URL repo mismatch: expected %s, got %s/%s",
expected_repo,
owner,
repo,
)
return False

return True


def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool:
"""Verify a GitHub webhook HMAC-SHA256 signature.

Prevents spoofed webhook payloads from untrusted sources.
"""
if not signature or not signature.startswith("sha256="):
return False

expected_sig = "sha256=" + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected_sig, signature)


def _system_comment(item: LLCWorkItem, body: str) -> LLCWorkItemComment:
return LLCWorkItemComment(
company_id=item.company_id,
work_item_id=item.id,
body=body,
author_agent_id=None,
author_user_id=None,
)


@router.post("/github")
async def handle_github_webhook(
request: Request,
session: AsyncSession = Depends(get_session),
x_github_event: Optional[str] = Header(None),
x_hub_signature_256: Optional[str] = Header(None),
) -> Dict[str, Any]:
"""Handle GitHub ``pull_request`` webhook events for PR auto-linking.

Auto-links the PR to a work item resolved from:
1. Branch name: ``llc/{work_item_id}`` (and variants)
2. PR body: ``Closes #llc-{work_item_id}``

On PR merge → transition the work item to ``in_review``.
On PR close without merge → add a comment to the work item.
"""
webhook_secret = os.environ.get("GITHUB_WEBHOOK_SECRET")
if not webhook_secret:
logger.error("GITHUB_WEBHOOK_SECRET not configured — rejecting webhook")
raise HTTPException(
status_code=503,
detail="Webhook endpoint not configured. Set GITHUB_WEBHOOK_SECRET.",
)

body = await request.body()
if not x_hub_signature_256:
logger.warning("GitHub webhook: missing signature header")
raise HTTPException(status_code=401, detail="Missing webhook signature")

if not verify_webhook_signature(body, x_hub_signature_256, webhook_secret):
logger.warning("GitHub webhook: invalid signature")
raise HTTPException(status_code=401, detail="Invalid webhook signature")

if x_github_event != "pull_request":
return {"status": "ignored", "reason": "Not a pull_request event"}

payload = json.loads(body)
action = payload.get("action")
pr = payload.get("pull_request", {})
pr_url = pr.get("html_url")
pr_number = pr.get("number")
pr_merged = pr.get("merged", False)
branch_name = pr.get("head", {}).get("ref", "")
pr_body = pr.get("body", "")
repo_full_name = payload.get("repository", {}).get("full_name", "")

work_item_id_str = extract_work_item_id_from_branch(branch_name)
if not work_item_id_str:
work_item_id_str = extract_work_item_id_from_body(pr_body)

if not work_item_id_str:
logger.info(
"GitHub webhook: no LLC work item ID found in branch '%s' or PR body",
branch_name,
)
return {"status": "ignored", "reason": "No LLC work item ID pattern found"}

try:
work_item_uuid = UUID(work_item_id_str)
except ValueError:
logger.warning("Invalid UUID in branch/body: %s", work_item_id_str)
return {"status": "error", "reason": "Invalid work item ID format"}

if pr_url and not validate_github_pr_url(pr_url, repo_full_name):
logger.warning(
"Invalid or mismatched PR URL: %s (expected repo: %s)",
pr_url,
repo_full_name,
)
return {"status": "error", "reason": "Invalid or mismatched PR URL"}

stmt = select(LLCWorkItem).where(LLCWorkItem.id == work_item_uuid)
result = await session.execute(stmt)
item = result.scalar_one_or_none()
if not item:
logger.warning("Work item %s not found", work_item_uuid)
return {"status": "error", "reason": "Work item not found"}

current_urls = item.linked_pr_urls or []
if pr_url and pr_url not in current_urls:
item.linked_pr_urls = current_urls + [pr_url]
session.add(_system_comment(item, f"🔗 GitHub PR #{pr_number} linked: {pr_url}"))
logger.info("Linked PR #%s to work item %s", pr_number, work_item_uuid)

if action == "closed" and pr_merged:
current_status = WorkItemStatus(item.status)
if current_status not in (WorkItemStatus.DONE, WorkItemStatus.CANCELLED):
item.status = WorkItemStatus.IN_REVIEW
session.add(
_system_comment(
item,
f"✅ PR #{pr_number} merged → transitioned from " f"{current_status.value} to in_review",
)
)
logger.info(
"PR #%s merged: transitioned work item %s to in_review",
pr_number,
work_item_uuid,
)
elif action == "closed" and not pr_merged:
session.add(_system_comment(item, f"❌ PR #{pr_number} closed without merge"))
logger.info("PR #%s closed without merge", pr_number)

await session.commit()

return {
"status": "processed",
"work_item_id": str(work_item_uuid),
"pr_url": pr_url,
"action": action,
}
48 changes: 48 additions & 0 deletions autobot-backend/llc/api/work_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
WorkItemStatus,
WorkItemType,
)
from ..models.work_item import LLCWorkItemComment
from ..scheduler.heartbeat_scheduler import HeartbeatScheduler
from ..services.activity_log import ActivityLogQuery, LLCActivityLogService
from ..services.attachment_service import (
Expand All @@ -75,6 +76,7 @@
)
from ..services.work_product_service import WorkProductService
from .activity import ActivityLogEntry, ActivityLogResponse
from .github_webhooks import validate_github_pr_url


class HumanClaimRequest(BaseModel):
Expand Down Expand Up @@ -189,6 +191,14 @@ class CommentCreate(BaseModel):
author_user_id: Optional[str] = None


class LinkPrRequest(BaseModel):
"""Link a GitHub PR to a work item (GH#9625)."""

pr_url: str
pr_number: Optional[int] = None
repo: Optional[str] = None


class HandoffAttachmentRequest(BaseModel):
attachment_id: str
filename: str
Expand Down Expand Up @@ -316,6 +326,7 @@ async def _item_to_dict(item: Any, session: AsyncSession) -> Dict[str, Any]:
"priority": item.priority,
"story_points": item.story_points,
"labels": item.labels,
"linked_pr_urls": item.linked_pr_urls or [], # GH#9625
"parent_id": str(item.parent_id) if item.parent_id else None,
"project_id": str(item.project_id) if item.project_id else None,
"sprint_id": str(item.sprint_id) if item.sprint_id else None,
Expand Down Expand Up @@ -540,6 +551,43 @@ async def transition_work_item(
raise HTTPException(status_code=404, detail="Internal server error")


@router.post("/{work_item_id}/link-pr")
async def link_pr(
work_item_id: str,
body: LinkPrRequest,
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
ctx: TenantContext = Depends(require_org_context),
) -> Dict[str, Any]:
"""Link a GitHub PR URL to a work item (GH#9625).

Called by LLC adapters/agents after opening a PR for the work item.
"""
# IDOR guard: verify item belongs to caller's org before mutating.
item = await _service().get(session, work_item_id)
if item is None or str(item.company_id) != str(ctx.org_id):
raise HTTPException(status_code=404, detail="Work item not found")

if not validate_github_pr_url(body.pr_url, body.repo):
raise HTTPException(status_code=422, detail="Invalid GitHub PR URL")

current_urls = item.linked_pr_urls or []
if body.pr_url not in current_urls:
item.linked_pr_urls = current_urls + [body.pr_url]
pr_label = f"#{body.pr_number}" if body.pr_number else body.pr_url
session.add(
LLCWorkItemComment(
company_id=item.company_id,
work_item_id=item.id,
body=f"🔗 GitHub PR {pr_label} linked: {body.pr_url}",
author_agent_id=None,
author_user_id=None,
)
)
await session.commit()
return await _item_to_dict(item, session)


@router.post("/{work_item_id}/claim")
async def claim_work_item(
work_item_id: str,
Expand Down
8 changes: 8 additions & 0 deletions autobot-backend/llc/models/work_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ class LLCWorkItem(Base):
acceptance_criteria: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True)
labels: Mapped[list] = mapped_column(JSONB, nullable=False, server_default=sa.text("'[]'::jsonb"))

# GitHub PR linking (GH#9625)
linked_pr_urls: Mapped[list] = mapped_column(
JSONB,
nullable=False,
default=list,
server_default=sa.text("'[]'::jsonb"),
)

# Status / priority
status: Mapped[str] = mapped_column(
sa.Enum(WorkItemStatus, name="workitemstatus", create_type=False),
Expand Down
Loading
Loading