первый коммит

This commit is contained in:
2026-02-27 21:28:09 +03:00
parent 1e376aff24
commit e8805ffe29
171 changed files with 6400 additions and 556 deletions

View File

@@ -0,0 +1,217 @@
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