from __future__ import annotations import re from typing import Protocol _STORY_ID_RE = re.compile(r"\b[A-Z][A-Z0-9_]*-\d+\b") class StoryCommitWriter(Protocol): def record_story_commit( self, *, story_id: str, project_id: str, title: str, commit_sha: str | None, branch: str | None, changed_files: list[str], summary: str, actor: str | None, ) -> None: ... class RepoCacheWriter(Protocol): def record_repo_cache( self, *, project_id: str, commit_sha: str | None, changed_files: list[str], summary: str, ) -> None: ... class RepoWebhookService: def __init__(self, story_writer: StoryCommitWriter, cache_writer: RepoCacheWriter | None = None) -> None: self._story_writer = story_writer self._cache_writer = cache_writer def process(self, *, payload: dict, provider: str | None = None, headers: dict | None = None) -> dict: resolved_provider = self._resolve_provider(provider=provider, payload=payload, headers=headers or {}) normalized = self._normalize(provider=resolved_provider, payload=payload) if not normalized: return {"accepted": False, "reason": "unsupported_or_invalid_payload"} cache_recorded = False if self._cache_writer is not None: self._cache_writer.record_repo_cache( project_id=normalized["project_id"], commit_sha=normalized["commit_sha"], changed_files=normalized["changed_files"], summary=normalized["summary"], ) cache_recorded = True story_id = self._extract_story_id(normalized["messages"]) if not story_id: return { "accepted": True, "indexed": False, "story_bound": False, "cache_recorded": cache_recorded, "reason": "story_id_not_found", } self._story_writer.record_story_commit( story_id=story_id, project_id=normalized["project_id"], title=f"Story {story_id}", commit_sha=normalized["commit_sha"], branch=normalized["branch"], changed_files=normalized["changed_files"], summary=normalized["summary"], actor=normalized["actor"], ) return { "accepted": True, "indexed": False, "story_bound": True, "cache_recorded": cache_recorded, "story_id": story_id, "project_id": normalized["project_id"], "commit_sha": normalized["commit_sha"], "changed_files": normalized["changed_files"], } def _resolve_provider(self, *, provider: str | None, payload: dict, headers: dict[str, str]) -> str: value = (provider or "").strip().lower() if value in {"gitea", "bitbucket"}: return value lowered = {str(k).lower(): str(v) for k, v in headers.items()} if "x-gitea-event" in lowered: return "gitea" if "x-event-key" in lowered: return "bitbucket" if isinstance(payload.get("commits"), list) and ("ref" in payload or "pusher" in payload): return "gitea" push = payload.get("push") if isinstance(push, dict) and isinstance(push.get("changes"), list): return "bitbucket" return "" def _normalize(self, *, provider: str, payload: dict) -> dict | None: key = provider.lower().strip() if key == "gitea": return self._normalize_gitea(payload) if key == "bitbucket": return self._normalize_bitbucket(payload) return None def _normalize_gitea(self, payload: dict) -> dict: repo = payload.get("repository") or {} commits = payload.get("commits") or [] project_id = str(repo.get("full_name") or repo.get("name") or "unknown_repo") ref = str(payload.get("ref") or "") branch = ref.replace("refs/heads/", "") if ref.startswith("refs/heads/") else ref or None actor = str((payload.get("pusher") or {}).get("username") or "") or None messages: list[str] = [] changed_files: set[str] = set() commit_sha: str | None = None for commit in commits: if not isinstance(commit, dict): continue cid = str(commit.get("id") or "").strip() if cid: commit_sha = cid msg = str(commit.get("message") or "").strip() if msg: messages.append(msg) for key in ("added", "modified", "removed"): for path in commit.get(key) or []: path_value = str(path).strip() if path_value: changed_files.add(path_value) summary = messages[-1] if messages else "Webhook commit without message" return { "project_id": project_id, "branch": branch, "commit_sha": commit_sha, "changed_files": sorted(changed_files), "messages": messages, "summary": summary, "actor": actor, } def _normalize_bitbucket(self, payload: dict) -> dict: repo = payload.get("repository") or {} project_id = str(repo.get("full_name") or repo.get("name") or "unknown_repo") changes = (((payload.get("push") or {}).get("changes")) or []) messages: list[str] = [] changed_files: set[str] = set() commit_sha: str | None = None branch: str | None = None actor = None actor_raw = payload.get("actor") or {} if isinstance(actor_raw, dict): actor = str(actor_raw.get("display_name") or actor_raw.get("username") or "") or None for change in changes: if not isinstance(change, dict): continue new_ref = change.get("new") or {} if isinstance(new_ref, dict): branch_name = str(new_ref.get("name") or "").strip() if branch_name: branch = branch_name target = new_ref.get("target") or {} if isinstance(target, dict): h = str(target.get("hash") or "").strip() if h: commit_sha = h msg = str(target.get("message") or "").strip() if msg: messages.append(msg) for commit in change.get("commits") or []: if not isinstance(commit, dict): continue h = str(commit.get("hash") or "").strip() if h: commit_sha = h msg = str(commit.get("message") or "").strip() if msg: messages.append(msg) for key in ("added", "modified", "removed"): for item in commit.get(key) or []: if isinstance(item, dict): path_value = str(item.get("path") or "").strip() else: path_value = str(item).strip() if path_value: changed_files.add(path_value) summary = messages[-1] if messages else "Webhook commit without message" return { "project_id": project_id, "branch": branch, "commit_sha": commit_sha, "changed_files": sorted(changed_files), "messages": messages, "summary": summary, "actor": actor, } def _extract_story_id(self, messages: list[str]) -> str | None: for msg in messages: match = _STORY_ID_RE.search(msg) if match: return match.group(0) return None