import asyncio import base64 import binascii import hashlib import hmac import math import os import re import secrets import sqlite3 import time from collections.abc import Awaitable, Coroutine from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any, Literal from urllib.parse import urlencode, urlsplit from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, PositiveInt, ValidationError, field_validator, model_validator from starlette.datastructures import UploadFile from src import dashboard_auth, gitea_proxy, passkeys from src.available_issue_snapshot_store import AvailableIssueSnapshotStore from src.compression import NegotiatedGZipMiddleware from src.gitea_proxy import ( activity_events, current_user, is_requested_review, issues, mark_notification_read, notification_detail, notifications, pull_requests, pull_review_detail, repos, ) from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source from src.live_snapshot_store import ( LiveSnapshotMetadata, LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost, ) from src.models import Issue, Milestone, PullRequest, Repo, User from src.passkey_store import PasskeyStore from src.push_notifications import ( PushConfiguration, dispatch_deadline_reminders, dispatch_unread_updates, ) from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint from src.push_subscription_store import PushSubscriptionStore from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.security_event_store import SecurityEventStore, SecurityEventStoreError from src.suggestion_engine import compute from src.later_store import LaterStore from src.today_store import TodayPlanFull, TodayStore from src.views import FRONTEND_BUILD, router as frontend_router async def _drain_authored_action_operations() -> None: for key, operation in list(_authored_action_operations.items()): if operation[1].done(): _authored_action_operations.pop(key, None) tasks = { operation[1] for operation in _authored_action_operations.values() if not operation[1].done() } if not tasks: return try: await asyncio.wait_for( asyncio.gather(*tasks, return_exceptions=True), timeout=AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS, ) except TimeoutError: pass finally: for key, operation in list(_authored_action_operations.items()): if operation[1] in tasks and operation[1].done(): _authored_action_operations.pop(key, None) async def _push_channel_loop(dispatch, *, interval: float) -> None: """Run one push channel on fixed ticks without overlapping or catch-up bursts.""" loop = asyncio.get_running_loop() next_tick = loop.time() + interval while True: await asyncio.sleep(max(0.0, next_tick - loop.time())) try: await dispatch() except asyncio.CancelledError: raise except Exception: pass now = loop.time() elapsed_intervals = max(1, int((now - next_tick) // interval) + 1) next_tick += elapsed_intervals * interval async def _push_poll_loop() -> None: interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30"))) deadline_interval = max( 60.0, float(os.getenv("STACKCHAIN_DEADLINE_POLL_SECONDS", "600")) ) send_timeout = max( 1.0, float(os.getenv("STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS", "10")) ) lease_seconds = max( send_timeout + 5.0, float(os.getenv("STACKCHAIN_PUSH_LEASE_SECONDS", "60")), ) max_concurrency = max( 1, int(os.getenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "8")) ) max_individual_notifications = max( 0, int(os.getenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "3")), ) async def dispatch_unread() -> None: await dispatch_unread_updates( _push_subscription_store, _push_configuration(), gitea_proxy.unread_notification_snapshot, session_statuses=dashboard_auth.managed_session_statuses, lease_seconds=lease_seconds, send_timeout_seconds=send_timeout, max_concurrency=max_concurrency, max_individual_notifications=max_individual_notifications, ) async def dispatch_deadlines() -> None: await dispatch_deadline_reminders( _push_subscription_store, _push_configuration(), gitea_proxy.assigned_issue_snapshot, session_statuses=dashboard_auth.managed_session_statuses, send_timeout_seconds=send_timeout, lease_seconds=lease_seconds, max_concurrency=max_concurrency, ) channel_tasks = ( asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)), asyncio.create_task( _push_channel_loop(dispatch_deadlines, interval=deadline_interval) ), ) try: done, _pending = await asyncio.wait( channel_tasks, return_when=asyncio.FIRST_COMPLETED ) await next(iter(done)) finally: for task in channel_tasks: if not task.done(): task.cancel() await asyncio.gather(*channel_tasks, return_exceptions=True) async def _readiness_monitor() -> None: while True: await _check_readiness() await asyncio.sleep(READINESS_INTERVAL_SECONDS) @asynccontextmanager async def lifespan(_app: FastAPI): global _live_snapshot_task, _available_issue_snapshot_task, _push_poll_task global _readiness_task gitea_proxy.start_client() _readiness_task = asyncio.create_task(_readiness_monitor()) if _push_configuration().enabled: _push_poll_task = asyncio.create_task(_push_poll_loop()) try: yield finally: live_task = _live_snapshot_task available_task = _available_issue_snapshot_task push_task = _push_poll_task readiness_task = _readiness_task for task in (live_task, available_task, push_task, readiness_task): if task is not None and not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass try: await _drain_authored_action_operations() await gitea_proxy.stop_client() finally: if _live_snapshot_task is live_task: _live_snapshot_task = None if _available_issue_snapshot_task is available_task: _available_issue_snapshot_task = None if _push_poll_task is push_task: _push_poll_task = None if _readiness_task is readiness_task: _readiness_task = None app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan) app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit) app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024) CONTEXT_TIMEOUT_SECONDS = 5.0 EVENT_STREAM_TIMEOUT_SECONDS = 5.0 READINESS_TIMEOUT_SECONDS = 5.0 READINESS_INTERVAL_SECONDS = max( READINESS_TIMEOUT_SECONDS, float(os.getenv("STACKCHAIN_READINESS_INTERVAL_SECONDS", "30")), ) READINESS_MAX_AGE_SECONDS = max( READINESS_INTERVAL_SECONDS + READINESS_TIMEOUT_SECONDS + 5.0, float(os.getenv("STACKCHAIN_READINESS_MAX_AGE_SECONDS", "65")), ) @dataclass(frozen=True) class ReadinessState: checked_at: float ready: bool login: str | None = None error: str | None = None timed_out: bool = False _readiness_state: ReadinessState | None = None async def _check_readiness() -> None: global _readiness_state checked_at = time.monotonic() try: user = await asyncio.wait_for(current_user(), timeout=READINESS_TIMEOUT_SECONDS) if not isinstance(user, dict) or not user.get("login"): raise ReadinessPayloadError( "Gitea current-user response did not include a login" ) except Exception as exc: timed_out = isinstance(exc, TimeoutError) error = ( f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s" if timed_out else ( str(exc) if isinstance(exc, ReadinessPayloadError) else "Gitea readiness check is temporarily unavailable" ) ) _readiness_state = ReadinessState( checked_at=checked_at, ready=False, error=error, timed_out=timed_out, ) return _readiness_state = ReadinessState( checked_at=checked_at, ready=True, login=user["login"], ) REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0 ISSUE_ACTION_TIMEOUT_SECONDS = 5.0 ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0 ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES = 256 AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS = 600.0 AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256 AUTHORED_ACTION_LEDGER_LOCK_TIMEOUT_SECONDS = float( os.getenv("STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS", "0.1") ) AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS = float( os.getenv("STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", "5.0") ) NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0 NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0 WORK_PAGE_TIMEOUT_SECONDS = 5.0 GLOBAL_SEARCH_TIMEOUT_SECONDS = 3.0 NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0 BULK_NOTIFICATION_CONCURRENCY = 5 BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0 LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0 LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0 LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0 AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0 AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0 AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0 FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" _live_snapshot_task: asyncio.Task | None = None _push_poll_task: asyncio.Task | None = None _readiness_task: asyncio.Task | None = None _live_snapshot_value: dict | None = None _live_snapshot_created_at: float | None = None LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications") _live_section_created_at: dict[str, float | None] = { section: None for section in LIVE_SNAPSHOT_SECTIONS } _live_section_failure_count: dict[str, int] = { section: 0 for section in LIVE_SNAPSHOT_SECTIONS } _live_section_retry_at: dict[str, float | None] = { section: None for section in LIVE_SNAPSHOT_SECTIONS } _live_snapshot_refreshing_sections: set[str] = set() _live_section_revisions: dict[str, int] = { section: 0 for section in LIVE_SNAPSHOT_SECTIONS } _live_revision_generation = secrets.token_hex(8) _read_notification_ids: set[int] = set() _authored_action_operations: dict[ str, tuple[tuple[Any, ...], asyncio.Task, float] ] = {} _state_dir = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")) _live_snapshot_clock = time.time _live_snapshot_store = LiveSnapshotStore( os.getenv("STACKCHAIN_LIVE_SNAPSHOT_DB", str(_state_dir / "live-snapshot.sqlite3")), clock=lambda: _live_snapshot_clock(), ) _idempotency_ledger = IdempotencyLedger( os.getenv("STACKCHAIN_IDEMPOTENCY_DB", str(_state_dir / "idempotency.sqlite3")), ttl_seconds=AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS, max_entries=( AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES + ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES ), lock_timeout_seconds=AUTHORED_ACTION_LEDGER_LOCK_TIMEOUT_SECONDS, ) _available_issue_snapshot_task: asyncio.Task | None = None _available_issue_snapshot_lock = asyncio.Lock() _available_issue_snapshot_value: list[dict] | None = None _available_issue_snapshot_created_at: float | None = None _available_issue_snapshot_retry_at: float | None = None _available_issue_snapshot_store = AvailableIssueSnapshotStore( os.getenv( "STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB", str(_state_dir / "available-issue-snapshot.sqlite3"), ) ) _push_subscription_store = PushSubscriptionStore( os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3")) ) def _push_configuration() -> PushConfiguration: return PushConfiguration( os.getenv("STACKCHAIN_VAPID_PUBLIC_KEY", "").strip(), os.getenv("STACKCHAIN_VAPID_PRIVATE_KEY", "").strip(), os.getenv("STACKCHAIN_VAPID_SUBJECT", "").strip(), ) class ContextPayloadError(ValueError): """Raised when Gitea returns a structurally invalid context payload.""" class ReadinessPayloadError(ValueError): """Raised when Gitea returns a structurally invalid readiness payload.""" class DashboardSignIn(BaseModel): access_token: str = Field(min_length=1, max_length=1_024) device_label: str = Field(default="This device", min_length=1, max_length=64) @field_validator("device_label") @classmethod def normalize_device_label(cls, value: str) -> str: normalized = " ".join(value.split()) if not normalized: raise ValueError("device label cannot be blank") return normalized class PushSubscriptionPayload(BaseModel): endpoint: str = Field(min_length=12, max_length=2_048, pattern=r"^https://") keys: dict[str, str] @field_validator("keys") @classmethod def validate_push_keys(cls, value: dict[str, str]) -> dict[str, str]: if set(value) != {"p256dh", "auth"} or any( not item or len(item) > 1_024 for item in value.values() ): raise ValueError("Valid Web Push keys are required") return value class DeadlineReminderPayload(BaseModel): enabled: bool timezone: str = Field(min_length=1, max_length=64) reminder_hour: int = Field(default=9, ge=0, le=23) reminder_days: Literal[0, 2, 7] = 2 @field_validator("timezone") @classmethod def validate_timezone(cls, value: str) -> str: from zoneinfo import ZoneInfo, ZoneInfoNotFoundError try: ZoneInfo(value) except ZoneInfoNotFoundError as error: raise ValueError("Valid IANA timezone required") from error return value StepUpAction = Literal[ "merge_pull", "submit_pull_review", "close_issue", "delete_comment", "log_recap_time", "revoke_device", "revoke_all_sessions", "enroll_passkey", "revoke_passkey", ] class FreshAuthorization(BaseModel): access_token: str = Field(min_length=1, max_length=1_024) action: StepUpAction target: str = Field(min_length=1, max_length=255) class PasskeyCeremony(BaseModel): challenge: str = Field(min_length=20, max_length=200) credential: dict = Field() class PasskeyAuthentication(PasskeyCeremony): device_label: str = Field(default="This device", min_length=1, max_length=64) action: Literal[ "sign_in", "merge_pull", "close_issue", "revoke_device", "revoke_all_sessions" ] = "sign_in" target: str = Field(default="dashboard", min_length=1, max_length=255) class PasskeyAuthorizationTarget(BaseModel): action: StepUpAction target: str = Field(min_length=1, max_length=255) class PasskeyAuthorization(PasskeyCeremony, PasskeyAuthorizationTarget): pass def _passkey_store() -> PasskeyStore: state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") database = os.getenv( "STACKCHAIN_SESSION_DB", os.path.join(state_dir, "sessions.sqlite3") ) return PasskeyStore( database, clock=time.time, max_challenges=int(os.getenv("STACKCHAIN_PASSKEY_MAX_CHALLENGES", "10000")), max_challenges_per_source=int( os.getenv("STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE", "10") ), ) def _security_event_store() -> SecurityEventStore: state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") database = os.getenv( "STACKCHAIN_SECURITY_EVENT_DB", os.path.join(state_dir, "security-events.sqlite3"), ) return SecurityEventStore(database, clock=time.time) def _passkey_relying_party(request: Request) -> tuple[str, str]: canonical_origin = dashboard_auth.public_origin() canonical_host = urlsplit(canonical_origin).hostname if canonical_origin else None rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", canonical_host or request.url.hostname or "") origin = os.getenv( "STACKCHAIN_PASSKEY_ORIGIN", canonical_origin or f"{request.url.scheme}://{request.url.netloc}", ) return rp_id, origin def _login_attempt_store() -> LoginAttemptStore: state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") return LoginAttemptStore( os.getenv( "STACKCHAIN_LOGIN_ATTEMPT_DB", os.path.join(state_dir, "login-attempts.sqlite3"), ), clock=time.time, max_failures=int(os.getenv("STACKCHAIN_LOGIN_MAX_FAILURES", "5")), window_seconds=int(os.getenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "300")), max_entries=int(os.getenv("STACKCHAIN_LOGIN_MAX_ENTRIES", "10000")), ) async def _require_step_up( request: Request, grant: str | None, *, action: str, target: str, ) -> None: if dashboard_auth.mode() != dashboard_auth.OPERATOR_MODE: return try: valid = bool(grant) and await dashboard_auth.consume_step_up( grant, request.state.dashboard_session, action=action, target=target ) except dashboard_auth.SessionStoreError: raise HTTPException( status_code=503, detail="Session registry is temporarily unavailable" ) if not valid: return_payload = { "detail": "Fresh authorization required", "code": "step_up_required", "action": action, "target": target, } raise HTTPException(status_code=428, detail=return_payload) class NotificationReadBatch(BaseModel): ids: list[PositiveInt] = Field(min_length=1, max_length=50) class TodayOperation(BaseModel): operation_id: str = Field(min_length=1, max_length=100) action: Literal["add", "remove", "move", "configure", "rollover"] item_id: str = Field(min_length=1, max_length=500) direction: Literal["up", "down"] | None = None base_revision: int | None = Field(default=None, ge=0) capacity_minutes: int | None = Field(default=None, ge=15, le=1440) estimates: dict[str, int] = Field(default_factory=dict, max_length=5) plan_date: str | None = Field(default=None, min_length=10, max_length=10) timezone: str | None = Field(default=None, min_length=1, max_length=100) ids: list[str] = Field(default_factory=list, max_length=5) @model_validator(mode="after") def validate_action_fields(self): if self.action == "move" and self.direction is None: raise ValueError("move requires a direction") if self.action != "move" and self.direction is not None: raise ValueError("direction is only valid for move") if self.action not in {"configure", "rollover"} and ( self.capacity_minutes is not None or self.estimates ): raise ValueError("capacity and estimates are only valid for configure or rollover") if self.action == "rollover": if self.item_id != "plan" or self.plan_date is None or self.timezone is None: raise ValueError("rollover requires plan date, timezone, and plan item") if len(set(self.ids)) != len(self.ids): raise ValueError("rollover IDs must be unique") elif self.plan_date is not None or self.timezone is not None or self.ids: raise ValueError("date, timezone, and IDs are only valid for rollover") if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440 for item_id, minutes in self.estimates.items()): raise ValueError("estimates must use bounded item IDs and minutes") return self def model_dump(self, *args, **kwargs): data = super().model_dump(*args, **kwargs) if self.action != "rollover": data.pop("plan_date", None) data.pop("timezone", None) data.pop("ids", None) return data class TodayRecapItem(BaseModel): identity: str = Field(min_length=1, max_length=500) estimate_minutes: int | None = Field(default=None, ge=5, le=1440) actual_minutes: int = Field(ge=0, le=1440) class TodayRecap(BaseModel): session_id: str = Field(min_length=1, max_length=100) items: list[TodayRecapItem] = Field(min_length=1, max_length=20) class TodayRecapTimeLog(TodayRecap): log_identities: list[str] = Field(min_length=1, max_length=20) def _recap_time_log_target(payload: TodayRecapTimeLog) -> str: selected_minutes = { item.identity: item.actual_minutes for item in payload.items if item.identity in payload.log_identities } material = "\0".join( [payload.session_id] + [f"{identity}\0{selected_minutes.get(identity, '')}" for identity in sorted(payload.log_identities)] ) return f"today-recap:{hashlib.sha256(material.encode()).hexdigest()[:32]}" class LaterOperation(BaseModel): operation_id: str = Field(min_length=1, max_length=100) action: Literal["defer", "restore"] item_id: str = Field(min_length=1, max_length=500) wake_at: str | None = Field(default=None, max_length=100) base_revision: int | None = Field(default=None, ge=0) class TodayOperationBatch(BaseModel): operations: list[TodayOperation] = Field(min_length=1, max_length=50) class LaterOperationBatch(BaseModel): operations: list[LaterOperation] = Field(min_length=1, max_length=50) class NotificationLaterRequest(BaseModel): wake_at: str = Field(min_length=1, max_length=100) class NotificationReply(BaseModel): body: str = Field(min_length=1, max_length=10_000) @field_validator("body") @classmethod def strip_body(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("reply must not be blank") return value class IssueComment(BaseModel): body: str = Field(min_length=1, max_length=10_000) @field_validator("body") @classmethod def strip_body(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("comment must not be blank") return value def _validate_attachment_metadata(filename: str, content_type: str) -> None: expected = { "image/png": {"png"}, "image/jpeg": {"jpg", "jpeg"}, "image/webp": {"webp"}, } if content_type not in expected: raise ValueError("attachment content type must be PNG, JPEG, or WebP") if "/" in filename or "\\" in filename or any(ord(character) < 32 for character in filename): raise ValueError("attachment filename must be a plain file name") extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" if extension not in expected[content_type]: raise ValueError("attachment filename must match the selected image type") def _validate_attachment_content(content_type: str, content: bytes) -> bytes: if len(content) > 2 * 1024 * 1024: raise ValueError("screenshot must be 2 MB or smaller") signatures = { "image/png": content.startswith(b"\x89PNG\r\n\x1a\n"), "image/jpeg": content.startswith(b"\xff\xd8\xff"), "image/webp": content.startswith(b"RIFF") and content[8:12] == b"WEBP", } if not signatures.get(content_type, False): raise ValueError("file contents do not match the selected image type") return content class IssueAttachment(BaseModel): filename: str = Field(min_length=1, max_length=255) content_type: Literal["image/png", "image/jpeg", "image/webp"] data: str = Field(min_length=1, max_length=2_800_000) @model_validator(mode="after") def validate_filename(self): _validate_attachment_metadata(self.filename, self.content_type) return self def content(self) -> bytes: try: content = base64.b64decode(self.data, validate=True) except (ValueError, binascii.Error) as exc: raise ValueError("attachment data must be valid base64") from exc return _validate_attachment_content(self.content_type, content) def _validate_binary_attachment(filename: str, content_type: str, content: bytes) -> bytes: _validate_attachment_metadata(filename, content_type) return _validate_attachment_content(content_type, content) class IssueCreation(BaseModel): title: str = Field(min_length=1, max_length=255) body: str = Field(default="", max_length=10_000) label_ids: list[int] = Field(default_factory=list, max_length=20) milestone_id: PositiveInt | None = None due_date: str | None = Field( default=None, pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", max_length=20, ) @field_validator("title") @classmethod def strip_title(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("title must not be blank") return value @field_validator("body") @classmethod def strip_issue_body(cls, value: str) -> str: return value.strip() @field_validator("due_date") @classmethod def validate_due_date(cls, value: str | None) -> str | None: if value is not None: datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") return value class IssueContentUpdate(BaseModel): title: str = Field(min_length=1, max_length=255) body: str = Field(default="", max_length=10_000) expected_updated_at: str = Field(min_length=1, max_length=64) @field_validator("title") @classmethod def strip_title(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("title must not be blank") return value @field_validator("body") @classmethod def strip_body(cls, value: str) -> str: return value.strip() class IssueLabelUpdate(BaseModel): label_ids: list[PositiveInt] = Field(max_length=20) class IssueDueDateUpdate(BaseModel): due_date: str | None = Field( default=None, pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", max_length=20, ) class IssueMilestoneUpdate(BaseModel): milestone_id: PositiveInt | None = None class IssueHandoff(BaseModel): recipient: str = Field( min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$" ) class IssueBlockerUpdate(BaseModel): repository: str = Field( min_length=3, max_length=255, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", ) number: PositiveInt class IssueBlockerDesiredState(IssueBlockerUpdate): present: bool class PullReviewComment(BaseModel): path: str = Field(min_length=1, max_length=1_000) body: str = Field(min_length=1, max_length=10_000) new_position: PositiveInt | None = None old_position: PositiveInt | None = None @field_validator("path", "body") @classmethod def strip_comment_text(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("inline comment values must not be blank") return value @model_validator(mode="after") def require_one_position(self): if (self.new_position is None) == (self.old_position is None): raise ValueError("inline comments require exactly one old or new position") return self class PullReviewSubmission(BaseModel): decision: str body: str = Field(max_length=10_000) expected_head_sha: str comments: list[PullReviewComment] = Field(default_factory=list, max_length=50) @field_validator("decision") @classmethod def validate_decision(cls, value: str) -> str: if value not in {"comment", "approve", "request_changes"}: raise ValueError("unsupported review decision") return value class PullMergeSubmission(BaseModel): expected_head_sha: str = Field(min_length=1, max_length=128) async def _run_idempotent_authored_action( operation: Coroutine[Any, Any, Any], *, idempotency_key: str | None, fingerprint: tuple[Any, ...], timeout: float, ): if not idempotency_key: return await asyncio.wait_for(operation, timeout=timeout) reservation = await asyncio.to_thread( _idempotency_ledger.reserve, idempotency_key, fingerprint ) if reservation.state == "conflict": operation.close() raise HTTPException(status_code=409, detail="Idempotency key already used") if reservation.state == "completed": operation.close() return reservation.response if reservation.state == "busy": operation.close() raise HTTPException( status_code=503, detail="Authored action queue is busy; please retry", headers={"Retry-After": "1"}, ) existing = _authored_action_operations.get(idempotency_key) if reservation.state == "uncertain" and ( existing is None or existing[0] != fingerprint ): operation.close() raise HTTPException( status_code=422, detail={ "code": "delivery_uncertain", "message": ( "Delivery could not be confirmed. Verify it was not posted before retrying." ), }, ) if reservation.state in {"pending", "uncertain"}: operation.close() if existing is None or existing[0] != fingerprint: raise HTTPException( status_code=503, detail=( "This action may still be completing; verify its result before retrying" ), headers={"Retry-After": "5"}, ) task = existing[1] else: async def persist_result(): result = await operation await asyncio.to_thread(_idempotency_ledger.complete, idempotency_key, result) return result task = asyncio.create_task(persist_result()) _authored_action_operations[idempotency_key] = ( fingerprint, task, time.monotonic(), ) def discard_completed(completed: asyncio.Task) -> None: existing_operation = _authored_action_operations.get(idempotency_key) if existing_operation is not None and existing_operation[1] is completed: _authored_action_operations.pop(idempotency_key, None) task.add_done_callback(discard_completed) try: return await asyncio.wait_for(asyncio.shield(task), timeout=timeout) except IdempotencyLedgerBusy: raise HTTPException( status_code=503, detail="This action may have completed; verify its result before retrying", headers={"Retry-After": "5"}, ) def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: user_model = User( id=user_data["id"], login=user_data["login"], full_name=user_data.get("full_name") or "", email=user_data.get("email") or "", ) repo_models = [ Repo( id=r["id"], name=r["name"], full_name=r["full_name"], description=r.get("description") or "", url=r["html_url"], updated_at=r.get("updated_at", ""), ) for r in (repo_data or [])[:50] if isinstance(r, dict) and all(field in r for field in ("id", "name", "full_name", "html_url")) ] issue_models = [ Issue( id=i["id"], number=i["number"], title=i["title"], state=i["state"], labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)], assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)], repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "", updated_at=i.get("updated_at") or "", due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None, milestone=Milestone(**i["milestone"]) if isinstance(i.get("milestone"), dict) and isinstance(i["milestone"].get("id"), int) and isinstance(i["milestone"].get("title"), str) else None, url=i["html_url"], ) for i in (issues_data or [])[:50] if isinstance(i, dict) and all(field in i for field in ("id", "number", "title", "state", "html_url")) ] pr_models = [ PullRequest( id=p["id"], number=p["number"], title=p["title"], state=p["state"], user=p["user"].get("login", "") if isinstance(p.get("user"), dict) else "", labels=[label.get("name", "") for label in (p.get("labels") or []) if isinstance(label, dict)], assignees=[assignee.get("login", "") for assignee in (p.get("assignees") or []) if isinstance(assignee, dict)], work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested")], repository=p["repository"].get("full_name", "") if isinstance(p.get("repository"), dict) else "", updated_at=p.get("updated_at") or "", url=p["html_url"], ) for p in (prs_data or [])[:50] if isinstance(p, dict) and all(field in p for field in ("id", "number", "title", "state", "html_url")) ] payload = compute(user_model, repo_models, issue_models, pr_models).model_dump() repository_pagination = getattr(repo_data, "pagination", None) if isinstance(repository_pagination, dict): payload["repository_pagination"] = repository_pagination pagination = {} pagination.update(getattr(issues_data, "pagination", {})) pagination.update(getattr(prs_data, "pagination", {})) if pagination: payload["work_pagination"] = pagination return payload def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]: if stream == "issue": return [ Issue( id=item["id"], number=item["number"], title=item["title"], state=item["state"], labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)], assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)], repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "", updated_at=item.get("updated_at") or "", due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None, milestone=Milestone(**item["milestone"]) if isinstance(item.get("milestone"), dict) and isinstance(item["milestone"].get("id"), int) and isinstance(item["milestone"].get("title"), str) else None, url=item["html_url"], ).model_dump() for item in items if all(field in item for field in ("id", "number", "title", "state", "html_url")) ] reason = "assigned_to_me" if stream == "pull" else "review_requested" return [ PullRequest( id=item["id"], number=item["number"], title=item["title"], state=item["state"], user=item["user"].get("login", "") if isinstance(item.get("user"), dict) else "", labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)], assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)], work_reasons=[reason], repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "", updated_at=item.get("updated_at") or "", url=item["html_url"], ).model_dump() for item in items if all(field in item for field in ("id", "number", "title", "state", "html_url")) ] app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") app.include_router(frontend_router) def _share_target_login_redirect(request: Request) -> str: search_values = request.query_params.getlist("search") preview_values = request.query_params.getlist("preview") if search_values or preview_values: allowed = {"search", "preview", "search_kind", "search_state", "search_repository"} kind_values = request.query_params.getlist("search_kind") state_values = request.query_params.getlist("search_state") repository_values = request.query_params.getlist("search_repository") if ( set(request.query_params.keys()) - allowed or len(search_values) != 1 or len(preview_values) != 1 or len(kind_values) > 1 or len(state_values) > 1 or len(repository_values) > 1 or len(search_values[0]) > 200 or len(preview_values[0]) > 200 or not re.fullmatch( r"(?:issue|pull):[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+:[1-9]\d*", preview_values[0], ) ): return "login" continuation_values = [ ("search", search_values[0].strip()), ("preview", preview_values[0]), ] if kind_values and kind_values[0] in {"all", "issue", "pull"}: continuation_values.append(("search_kind", kind_values[0])) if state_values and state_values[0] in {"all", "open", "closed"}: continuation_values.append(("search_state", state_values[0])) if repository_values and re.fullmatch( r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository_values[0] ): continuation_values.append(("search_repository", repository_values[0])) continuation = urlencode(continuation_values) return f"login?{urlencode({'continue': f'./?{continuation}'})}" limits = {"title": 200, "text": 8000, "url": 2048} if any( len(request.query_params.get(name, "")) > limit for name, limit in limits.items() ): return "login" shared = [ (name, request.query_params[name]) for name in limits if request.query_params.get(name) ] launch = request.query_params.get("launch", "") if launch in {"continue", "new", "agenda"}: shared.append(("launch", launch)) image_markers = request.query_params.getlist("shared") if image_markers: if ( image_markers != ["image"] or request.query_params.getlist("launch") != ["new"] ): return "login" shared.append(("shared", "image")) if dashboard_auth.application_path(request) != "/" or not shared: return "login" continuation = f"./?{urlencode(shared)}" return f"login?{urlencode({'continue': continuation})}" @app.middleware("http") async def require_operator_session(request: Request, call_next): path = dashboard_auth.application_path(request) canonical_origin = dashboard_auth.public_origin() if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE and canonical_origin: expected_authority = urlsplit(canonical_origin).netloc if request.url.netloc.lower() != expected_authority: return JSONResponse( {"detail": "Request host does not match the configured public origin"}, status_code=421, headers={"Cache-Control": "no-store"}, ) if path == "/healthz": return await call_next(request) if dashboard_auth.configuration_error() is not None: return JSONResponse( {"detail": "Dashboard authentication is not configured"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if dashboard_auth.mode() == dashboard_auth.INSECURE_LOCAL_MODE: if not dashboard_auth.is_loopback_request(request): return JSONResponse( {"detail": "Insecure local mode requires a loopback client"}, status_code=403, headers={"Cache-Control": "no-store"}, ) return await call_next(request) public = ( path in { "/healthz", "/readyz", "/login", "/manifest.webmanifest", "/" + FRONTEND_BUILD.runtime_name, } or path.startswith("/static/") or (path == "/api/v1/session" and request.method == "POST") or ( path in { "/api/v1/passkeys/authentication/options", "/api/v1/passkeys/authentication/verify", } and request.method == "POST" ) ) session = None session_reason = None if not public: try: verification = await dashboard_auth.request_session_verification(request) session = verification.session session_reason = verification.reason except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if not public and session is None: if path.startswith("/api/"): payload = {"detail": "Authentication required"} if session_reason in {"session_revoked", "session_idle"}: payload["code"] = session_reason return JSONResponse( payload, status_code=401, headers={"Cache-Control": "no-store"}, ) if session_reason == "session_revoked": login_redirect = "login?reason=session-revoked" elif session_reason == "session_idle": login_redirect = "login?reason=session-idle" else: login_redirect = _share_target_login_redirect(request) return RedirectResponse( login_redirect, status_code=303, headers={"Cache-Control": "no-store"}, ) if not public and request.method not in {"GET", "HEAD", "OPTIONS"}: supplied_csrf = request.headers.get("x-csrf-token", "") if ( session is None or not dashboard_auth.same_origin(request) or not supplied_csrf or not hmac.compare_digest(supplied_csrf, session.csrf) ): return JSONResponse( {"detail": "Valid same-origin CSRF proof required"}, status_code=403, headers={"Cache-Control": "no-store"}, ) request.state.dashboard_session = session response = await call_next(request) if path in {"/login", "/api/v1/session"}: response.headers["Cache-Control"] = "no-store" return response @app.middleware("http") async def prevent_live_api_caching(request, call_next): response = await call_next(request) path = dashboard_auth.application_path(request) if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( path.startswith("/api/v1/repos/") and path.endswith("/review") ) or path.startswith("/api/v1/notifications") or ( path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path) ) or ( path.startswith("/api/v1/repos/") and path.endswith("/issues") ) or ( path.startswith("/api/v1/repos/") and path.endswith(("/labels", "/milestones")) ): response.headers["Cache-Control"] = "no-store" return response CONTENT_SECURITY_POLICY = "; ".join( ( "default-src 'self'", "script-src 'self'", "connect-src 'self'", "img-src 'self' data:", "style-src 'self' 'unsafe-inline'", "worker-src 'self'", "manifest-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", ) ) @app.exception_handler(RequestValidationError) async def redact_request_validation_error( _request: Request, error: RequestValidationError ) -> JSONResponse: """Return useful validation locations without reflecting submitted values.""" details = [ {key: item[key] for key in ("type", "loc", "msg") if key in item} for item in error.errors() ] return JSONResponse( {"detail": details}, status_code=422, headers={"Cache-Control": "no-store"}, ) @app.middleware("http") async def enforce_browser_security_boundary(request: Request, call_next): """Apply one browser trust boundary, including to auth short-circuits.""" response = await call_next(request) response.headers["Content-Security-Policy"] = CONTENT_SECURITY_POLICY response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" response.headers["Permissions-Policy"] = ( "camera=(), microphone=(), geolocation=(), payment=(), usb=()" ) response.headers["X-Frame-Options"] = "DENY" if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE: response.headers["Strict-Transport-Security"] = "max-age=31536000" return response @app.get("/healthz") def health() -> dict[str, str]: """Return process liveness without depending on Gitea.""" return {"status": "ok", "service": "stackchain-dashboard"} @app.post("/api/v1/session") async def sign_in(payload: DashboardSignIn, request: Request, response: Response): peer_host = request.client.host if request.client is not None else "unknown" source = client_source( peer_host, request.headers.get("x-forwarded-for", ""), os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""), ) attempts = _login_attempt_store() try: retry_after = await asyncio.to_thread(attempts.retry_after, source) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if retry_after: try: await asyncio.to_thread(attempts.record_blocked, method="token") except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) return JSONResponse( {"detail": "Too many sign-in attempts"}, status_code=429, headers={ "Cache-Control": "no-store", "Retry-After": str(retry_after), }, ) configured_token = dashboard_auth.access_token() if not configured_token or not hmac.compare_digest( payload.access_token, configured_token ): try: await asyncio.to_thread(attempts.record_failure, source) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) raise HTTPException(status_code=401, detail="Invalid access token") try: await asyncio.to_thread(attempts.clear, source) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) try: signed, session = await asyncio.to_thread( dashboard_auth.issue_session, device_label=payload.device_label ) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) try: await asyncio.to_thread( _security_event_store().record, "sign_in", method="token", device_label=payload.device_label, target="dashboard", ) except SecurityEventStoreError: await asyncio.to_thread(dashboard_auth.revoke_session, session) return JSONResponse( {"detail": "Security activity is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) path = dashboard_auth.cookie_path(request) max_age = max(1, session.expires_at - int(time.time())) response.set_cookie( dashboard_auth.SESSION_COOKIE, signed, max_age=max_age, path=path, secure=True, httponly=True, samesite="strict", ) response.set_cookie( dashboard_auth.CSRF_COOKIE, session.csrf, max_age=max_age, path=path, secure=True, httponly=False, samesite="strict", ) response.headers["Cache-Control"] = "no-store" return {"authenticated": True} @app.get("/api/v1/security-events") async def list_security_events( limit: int = Query(default=25, ge=1, le=100), cursor: int | None = Query(default=None, ge=1), ): try: page = await asyncio.to_thread( _security_event_store().list, limit=limit, cursor=cursor ) authentication_alerts = await asyncio.to_thread( _login_attempt_store().list_alerts, limit=24 ) except (SecurityEventStoreError, LoginAttemptStoreError): raise HTTPException( status_code=503, detail="Security activity is temporarily unavailable" ) return JSONResponse( { "events": [ { "id": event.id, "kind": event.kind, "method": event.method, "device_label": event.device_label, "target": event.target, "created_at": event.created_at, "status": event.status, } for event in page.events ], "authentication_alerts": authentication_alerts, "next_cursor": page.next_cursor, }, headers={"Cache-Control": "no-store"}, ) @app.post("/api/v1/fresh-authorization", status_code=201) async def fresh_authorization(payload: FreshAuthorization, request: Request): peer_host = request.client.host if request.client is not None else "unknown" source = client_source( peer_host, request.headers.get("x-forwarded-for", ""), os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""), ) attempts = _login_attempt_store() try: retry_after = await asyncio.to_thread(attempts.retry_after, source) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if retry_after: return JSONResponse( {"detail": "Too many sign-in attempts"}, status_code=429, headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)}, ) configured_token = dashboard_auth.access_token() if not configured_token or not hmac.compare_digest( payload.access_token, configured_token ): try: await asyncio.to_thread(attempts.record_failure, source) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) raise HTTPException(status_code=401, detail="Invalid access token") try: await asyncio.to_thread(attempts.clear, source) grant = await dashboard_auth.issue_step_up( request.state.dashboard_session, action=payload.action, target=payload.target, ) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) return JSONResponse( {"grant": grant, "expires_in": dashboard_auth.STEP_UP_TTL_SECONDS}, status_code=201, headers={"Cache-Control": "no-store"}, ) @app.post("/api/v1/passkeys/registration/options", status_code=201) async def create_passkey_registration_options( request: Request, step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): await _require_step_up( request, step_up_grant, action="enroll_passkey", target="current_device", ) rp_id, _origin = _passkey_relying_party(request) store = _passkey_store() existing = await asyncio.to_thread(store.all) options, challenge = passkeys.registration_options( rp_id=rp_id, excluded=[item.credential_id for item in existing], ) await asyncio.to_thread( store.issue_challenge, challenge, session_id=request.state.dashboard_session.session_id, purpose="registration", action="enroll_passkey", target="current_device", ) return JSONResponse( options, status_code=201, headers={"Cache-Control": "no-store"}, ) @app.post("/api/v1/passkeys/registration/verify", status_code=201) async def verify_passkey_registration(payload: PasskeyCeremony, request: Request): try: challenge = passkeys.decode(payload.challenge) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid passkey ceremony") store = _passkey_store() valid = await asyncio.to_thread( store.consume_challenge, challenge, session_id=request.state.dashboard_session.session_id, purpose="registration", action="enroll_passkey", target="current_device", ) if not valid: raise HTTPException(status_code=409, detail="Passkey challenge expired or already used") rp_id, origin = _passkey_relying_party(request) try: verified = await asyncio.to_thread( passkeys.verify_registration, credential=payload.credential, challenge=challenge, rp_id=rp_id, origin=origin, ) devices = await dashboard_auth.active_devices(request.state.dashboard_session) current = next(device for device in devices if device.current) except dashboard_auth.SessionStoreError: raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable") except Exception as exc: raise HTTPException(status_code=400, detail="Passkey verification failed") from exc journal = _security_event_store() try: operation_id = await asyncio.to_thread( journal.reserve, "passkey_enrolled", method="passkey", device_label=current.device_label, target="passkey", ) except SecurityEventStoreError: raise HTTPException( status_code=503, detail="Security activity is temporarily unavailable" ) try: await asyncio.to_thread( store.register, credential_id=verified.credential_id, public_key=verified.credential_public_key, sign_count=verified.sign_count, device_label=current.device_label, management_id=current.management_id, ) except dashboard_auth.SessionStoreError: try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass raise HTTPException( status_code=503, detail="Passkey registry is temporarily unavailable" ) try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: pass return JSONResponse( {"enrolled": True}, status_code=201, headers={"Cache-Control": "no-store"} ) @app.get("/api/v1/passkeys") async def list_enrolled_passkeys(request: Request): try: credentials = await asyncio.to_thread(_passkey_store().all) devices = await dashboard_auth.active_devices(request.state.dashboard_session) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Passkey registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) active = {device.management_id: device for device in devices} return JSONResponse( { "passkeys": [ { "management_id": credential.management_id, "device_label": credential.device_label, "created_at": credential.created_at, "active": credential.management_id in active, "current": bool( credential.management_id in active and active[credential.management_id].current ), } for credential in credentials ] }, headers={"Cache-Control": "no-store"}, ) @app.delete("/api/v1/passkeys/{management_id}") async def revoke_enrolled_passkey( request: Request, management_id: str = PathParam( min_length=16, max_length=64, pattern=r"^[A-Za-z0-9_-]+$" ), step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): await _require_step_up( request, step_up_grant, action="revoke_passkey", target=management_id, ) store = _passkey_store() try: credential = await asyncio.to_thread(store.get_management_id, management_id) devices = await dashboard_auth.active_devices(request.state.dashboard_session) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Passkey registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if credential is None: raise HTTPException(status_code=404, detail="Enrolled passkey not found") active_device = next( (device for device in devices if device.management_id == management_id), None ) current_session = bool(active_device and active_device.current) journal = _security_event_store() try: operation_id = await asyncio.to_thread( journal.reserve, "passkey_revoked", device_label=credential.device_label, target="passkey", ) except SecurityEventStoreError: return JSONResponse( {"detail": "Security activity is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) try: revoked, session_revoked = await asyncio.to_thread( store.revoke_access, management_id, preserve_session=current_session, ) except dashboard_auth.SessionStoreError: try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass return JSONResponse( {"detail": "Passkey registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if not revoked: try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass raise HTTPException(status_code=404, detail="Enrolled passkey not found") try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: pass return JSONResponse( { "revoked": True, "current_session": current_session, "session_revoked": session_revoked, }, headers={"Cache-Control": "no-store"}, ) @app.post("/api/v1/passkeys/authentication/options") async def create_passkey_authentication_options(request: Request): store = _passkey_store() credentials = await asyncio.to_thread(store.all) if not credentials: raise HTTPException(status_code=404, detail="No passkeys enrolled") peer_host = request.client.host if request.client is not None else "unknown" source = client_source( peer_host, request.headers.get("x-forwarded-for", ""), os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""), ) attempts = _login_attempt_store() try: retry_after = await asyncio.to_thread(attempts.retry_after, source) if not retry_after: retry_after = await asyncio.to_thread( attempts.admit, "passkey_options", source, limit=int(os.getenv("STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS", "10")), ) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if retry_after: return JSONResponse( {"detail": "Too many passkey sign-in attempts"}, status_code=429, headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)}, ) rp_id, _origin = _passkey_relying_party(request) options, challenge = passkeys.authentication_options( rp_id=rp_id, credentials=[item.credential_id for item in credentials], ) await asyncio.to_thread( store.issue_challenge, challenge, session_id=None, purpose="authentication", action="sign_in", target="dashboard", source=source, ) return JSONResponse(options, headers={"Cache-Control": "no-store"}) @app.post("/api/v1/passkeys/authorization/options") async def create_passkey_authorization_options( payload: PasskeyAuthorizationTarget, request: Request ): store = _passkey_store() credentials = await asyncio.to_thread(store.all) if not credentials: raise HTTPException(status_code=404, detail="No passkeys enrolled") rp_id, _origin = _passkey_relying_party(request) options, challenge = passkeys.authentication_options( rp_id=rp_id, credentials=[item.credential_id for item in credentials], ) await asyncio.to_thread( store.issue_challenge, challenge, session_id=request.state.dashboard_session.session_id, purpose="authorization", action=payload.action, target=payload.target, ) return JSONResponse(options, headers={"Cache-Control": "no-store"}) @app.post("/api/v1/passkeys/authorization/verify", status_code=201) async def verify_passkey_authorization( payload: PasskeyAuthorization, request: Request ): try: challenge = passkeys.decode(payload.challenge) credential_id = passkeys.decode(str(payload.credential.get("id", ""))) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid passkey ceremony") store = _passkey_store() valid = await asyncio.to_thread( store.consume_challenge, challenge, session_id=request.state.dashboard_session.session_id, purpose="authorization", action=payload.action, target=payload.target, ) stored = await asyncio.to_thread(store.get, credential_id) if not valid or stored is None: raise HTTPException(status_code=409, detail="Passkey challenge expired or already used") rp_id, origin = _passkey_relying_party(request) try: verified = await asyncio.to_thread( passkeys.verify_authentication, credential=payload.credential, challenge=challenge, rp_id=rp_id, origin=origin, stored=stored, ) updated = await asyncio.to_thread( store.advance_counter, stored.credential_id, expected=stored.sign_count, new=verified.new_sign_count, ) if not updated: await asyncio.to_thread( _security_event_store().record, "passkey_counter_anomaly", method="passkey", device_label=stored.device_label, target=f"{payload.action}:{payload.target}", ) raise ValueError("stale passkey counter") grant = await dashboard_auth.issue_step_up( request.state.dashboard_session, action=payload.action, target=payload.target, ) except dashboard_auth.SessionStoreError: raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable") except Exception as exc: raise HTTPException(status_code=401, detail="Passkey authorization failed") from exc return JSONResponse( {"grant": grant, "expires_in": dashboard_auth.STEP_UP_TTL_SECONDS}, status_code=201, headers={"Cache-Control": "no-store"}, ) @app.post("/api/v1/passkeys/authentication/verify") async def verify_passkey_authentication( payload: PasskeyAuthentication, request: Request, response: Response ): if payload.action != "sign_in" or payload.target != "dashboard": raise HTTPException(status_code=400, detail="Invalid passkey sign-in target") peer_host = request.client.host if request.client is not None else "unknown" source = client_source( peer_host, request.headers.get("x-forwarded-for", ""), os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""), ) attempts = _login_attempt_store() try: retry_after = await asyncio.to_thread(attempts.retry_after, source) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if retry_after: return JSONResponse( {"detail": "Too many passkey sign-in attempts"}, status_code=429, headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)}, ) try: challenge = passkeys.decode(payload.challenge) credential_id = passkeys.decode(str(payload.credential.get("id", ""))) except (ValueError, TypeError): try: await asyncio.to_thread(attempts.record_failure, source) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) raise HTTPException(status_code=400, detail="Invalid passkey ceremony") store = _passkey_store() valid = await asyncio.to_thread( store.consume_challenge, challenge, session_id=None, purpose="authentication", action="sign_in", target="dashboard", ) stored = await asyncio.to_thread(store.get, credential_id) if not valid or stored is None: try: await asyncio.to_thread(attempts.record_failure, source) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) raise HTTPException(status_code=401, detail="Passkey sign-in failed") rp_id, origin = _passkey_relying_party(request) try: verified = await asyncio.to_thread( passkeys.verify_authentication, credential=payload.credential, challenge=challenge, rp_id=rp_id, origin=origin, stored=stored, ) updated = await asyncio.to_thread( store.advance_counter, stored.credential_id, expected=stored.sign_count, new=verified.new_sign_count, ) if not updated: await asyncio.to_thread( _security_event_store().record, "passkey_counter_anomaly", method="passkey", device_label=stored.device_label, target="sign_in:dashboard", ) raise ValueError("stale passkey counter") await dashboard_auth.revoke_managed_session(stored.management_id) signed, session = await asyncio.to_thread( dashboard_auth.issue_session, device_label=stored.device_label, management_id=stored.management_id, ) except dashboard_auth.SessionStoreError: raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable") except Exception as exc: try: await asyncio.to_thread(attempts.record_failure, source) except LoginAttemptStoreError: return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) raise HTTPException(status_code=401, detail="Passkey sign-in failed") from exc try: await asyncio.to_thread(attempts.clear, source) except LoginAttemptStoreError: await asyncio.to_thread(dashboard_auth.revoke_session, session) return JSONResponse( {"detail": "Sign-in throttling is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) try: await asyncio.to_thread( _security_event_store().record, "sign_in", method="passkey", device_label=stored.device_label, target="dashboard", ) except SecurityEventStoreError: await asyncio.to_thread(dashboard_auth.revoke_session, session) raise HTTPException( status_code=503, detail="Security activity is temporarily unavailable" ) path = dashboard_auth.cookie_path(request) max_age = max(1, session.expires_at - int(time.time())) response.set_cookie( dashboard_auth.SESSION_COOKIE, signed, max_age=max_age, path=path, secure=True, httponly=True, samesite="strict", ) response.set_cookie( dashboard_auth.CSRF_COOKIE, session.csrf, max_age=max_age, path=path, secure=True, httponly=False, samesite="strict", ) response.headers["Cache-Control"] = "no-store" return {"authenticated": True, "method": "passkey"} @app.get("/api/v1/session") async def session_status(request: Request): session = request.state.dashboard_session payload = { "authenticated": session is not None, "csrf_token": session.csrf if session is not None else "", } if session is not None: payload["expires_at"] = session.expires_at payload["idle_expires_at"] = session.idle_expires_at return payload @app.get("/api/v1/push-subscription") async def push_status(request: Request): configuration = _push_configuration() device_id = await dashboard_auth.session_management_id( request.state.dashboard_session ) subscribed = await asyncio.to_thread( _push_subscription_store.is_subscribed, device_id, ) preferences = await asyncio.to_thread( _push_subscription_store.deadline_preferences, device_id ) return { "available": configuration.enabled, "subscribed": subscribed, "public_key": configuration.public_key if configuration.enabled else "", "deadline_enabled": preferences["enabled"], "timezone": preferences["timezone"], "reminder_hour": preferences["reminder_hour"], "reminder_days": preferences["reminder_days"], } @app.put("/api/v1/push-subscription") async def subscribe_push(payload: PushSubscriptionPayload, request: Request): if not _push_configuration().enabled: raise HTTPException(status_code=503, detail="Push notifications are not configured") try: await validate_public_push_endpoint(payload.endpoint) except UnsafePushEndpoint as error: raise HTTPException(status_code=422, detail=str(error)) from error device_id = await dashboard_auth.session_management_id( request.state.dashboard_session ) await asyncio.to_thread( _push_subscription_store.upsert, device_id, payload.model_dump(), ) try: current = await gitea_proxy.unread_notification_snapshot( deadline_seconds=NOTIFICATION_PAGE_TIMEOUT_SECONDS ) except Exception as error: await asyncio.to_thread(_push_subscription_store.delete_session, device_id) raise HTTPException( status_code=503, detail="Unread updates are temporarily unavailable", headers={"Retry-After": "1"}, ) from error existing_revisions = { int(item["id"]): str(item.get("updated_at") or "") for item in current.get("items", []) if isinstance(item, dict) and str(item.get("id", "")).isdigit() } await asyncio.to_thread( _push_subscription_store.mark_delivered, device_id, existing_revisions ) return {"subscribed": True} @app.delete("/api/v1/push-subscription") async def unsubscribe_push(request: Request): device_id = await dashboard_auth.session_management_id( request.state.dashboard_session ) await asyncio.to_thread( _push_subscription_store.delete_session, device_id, ) return {"subscribed": False} @app.put("/api/v1/push-subscription/deadlines") async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request): device_id = await dashboard_auth.session_management_id( request.state.dashboard_session ) if payload.enabled and not await asyncio.to_thread( _push_subscription_store.is_subscribed, device_id ): raise HTTPException(status_code=409, detail="Enable device notifications first") await asyncio.to_thread( _push_subscription_store.set_deadline_preferences, device_id, enabled=payload.enabled, timezone=payload.timezone, reminder_hour=payload.reminder_hour, reminder_days=payload.reminder_days, ) return { "deadline_enabled": payload.enabled, "timezone": payload.timezone, "reminder_hour": payload.reminder_hour, "reminder_days": payload.reminder_days, } @app.post("/api/v1/session/activity") async def record_session_activity(request: Request): session = request.state.dashboard_session try: active = await dashboard_auth.touch_session(session) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if not active: return JSONResponse( {"detail": "Authentication required"}, status_code=401, headers={"Cache-Control": "no-store"}, ) return { "active": True, "idle_expires_at": min( session.expires_at, int(time.time()) + dashboard_auth.idle_timeout_seconds(), ), } def _today_store() -> TodayStore: return TodayStore( os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5 ) def _later_store() -> LaterStore: return LaterStore( os.getenv("STACKCHAIN_LATER_DB", str(_state_dir / "later.sqlite3")) ) async def _confirmed_login() -> str: try: user = await asyncio.wait_for( current_user(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS ) except Exception as exc: raise HTTPException( status_code=503, detail="Operator identity is unavailable" ) from exc login = user.get("login", "") if isinstance(user, dict) else "" if not isinstance(login, str) or not login.strip(): raise HTTPException(status_code=503, detail="Operator identity is unavailable") return login.strip().lower() @app.get("/api/v1/today") async def get_today_plan(): login = await _confirmed_login() try: return await asyncio.to_thread(_today_store().get, login) except (OSError, sqlite3.Error): raise HTTPException( status_code=503, detail="Today synchronization is unavailable", headers={"Retry-After": "1"}, ) @app.patch("/api/v1/today") async def update_today_plan(payload: TodayOperation | TodayOperationBatch): login = await _confirmed_login() try: if isinstance(payload, TodayOperationBatch): return await asyncio.to_thread( _today_store().apply_batch, login, [operation.model_dump() for operation in payload.operations], ) if payload.action == "configure": return await asyncio.to_thread( _today_store().apply_batch, login, [payload.model_dump()], ) return await asyncio.to_thread( _today_store().apply, login, payload.operation_id, payload.action, payload.item_id, direction=payload.direction, ) except TodayPlanFull: raise HTTPException(status_code=409, detail="Today is limited to 5 items") except (OSError, sqlite3.Error): raise HTTPException( status_code=503, detail="Today synchronization is unavailable", headers={"Retry-After": "1"}, ) @app.get("/api/v1/today/recaps") async def get_today_recaps(response: Response): login = await _confirmed_login() try: recaps = await asyncio.to_thread(_today_store().list_recaps, login) except (OSError, sqlite3.Error): raise HTTPException( status_code=503, detail="Today recap history is unavailable", headers={"Retry-After": "1"}, ) response.headers["Cache-Control"] = "no-store" return {"recaps": recaps} @app.post("/api/v1/today/recaps") async def save_today_recap(payload: TodayRecap, response: Response): login = await _confirmed_login() try: recap = await asyncio.to_thread( _today_store().save_recap, login, payload.session_id, [item.model_dump() for item in payload.items], ) except ValueError as error: raise HTTPException(status_code=422, detail=str(error)) except (OSError, sqlite3.Error): raise HTTPException( status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"}, ) response.headers["Cache-Control"] = "no-store" return recap @app.post("/api/v1/today/recaps/log-time") async def save_today_recap_and_log_time( payload: TodayRecapTimeLog, request: Request, response: Response, step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): login = await _confirmed_login() if len(set(payload.log_identities)) != len(payload.log_identities): raise HTTPException(status_code=422, detail="time log targets must be unique") requested_items = {item.identity: item for item in payload.items} selected = [] try: for identity in payload.log_identities: gitea_proxy.issue_time_target(identity) requested = requested_items.get(identity) if requested is None: raise ValueError("time log target must match the recap") if requested.actual_minutes <= 0: raise ValueError("only non-zero recap time can be logged") selected.append(requested) await _require_step_up( request, step_up_grant, action="log_recap_time", target=_recap_time_log_target(payload), ) recap = await asyncio.to_thread( _today_store().save_recap, login, payload.session_id, [item.model_dump() for item in payload.items], ) saved_items = {item["identity"]: item for item in recap["items"]} for requested in selected: saved = saved_items.get(requested.identity) if saved is None or saved["actual_minutes"] != requested.actual_minutes: raise ValueError("time log target must match the saved recap") except ValueError as error: raise HTTPException(status_code=422, detail=str(error)) except (OSError, sqlite3.Error): raise HTTPException(status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"}) semaphore = asyncio.Semaphore(3) async def log_selected_time(item): async with semaphore: result = {"identity": item.identity, "status": "retry"} try: state = await asyncio.to_thread( _today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes ) if state == "claimed": repository, number = gitea_proxy.issue_time_target(item.identity) journal = _security_event_store() try: operation_id = await asyncio.to_thread( journal.reserve, "gitea_time_logged", target=f"{repository}#{number}", ) except SecurityEventStoreError: await asyncio.to_thread( _today_store().finish_time_log, login, payload.session_id, item.identity, succeeded=False, ) return result try: await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60) except Exception as error: if gitea_proxy.time_log_failure_is_retryable(error): await asyncio.to_thread( _today_store().finish_time_log, login, payload.session_id, item.identity, succeeded=False, ) try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass status = "retry" else: status = "verify" return {"identity": item.identity, "status": status} await asyncio.to_thread( _today_store().finish_time_log, login, payload.session_id, item.identity, succeeded=True, ) try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: pass state = "logged" return { "identity": item.identity, "status": "logged" if state == "logged" else ("verify" if state == "pending" else "retry"), } except (ValueError, OSError, sqlite3.Error): return result results = await asyncio.gather(*(log_selected_time(item) for item in selected)) response.headers["Cache-Control"] = "no-store" return {**recap, "time_logs": results} @app.get("/api/v1/later") async def get_later_plan(): login = await _confirmed_login() try: return await asyncio.to_thread(_later_store().get, login) except (OSError, sqlite3.Error): raise HTTPException( status_code=503, detail="Later synchronization is unavailable", headers={"Retry-After": "1"}, ) @app.patch("/api/v1/later") async def update_later_plan(payload: LaterOperation | LaterOperationBatch): login = await _confirmed_login() try: if isinstance(payload, LaterOperationBatch): return await asyncio.to_thread( _later_store().apply_batch, login, [operation.model_dump() for operation in payload.operations], ) return await asyncio.to_thread( _later_store().apply, login, payload.operation_id, payload.action, payload.item_id, wake_at=payload.wake_at, base_revision=payload.base_revision, ) except ValueError as error: raise HTTPException(status_code=422, detail=str(error)) except (OSError, sqlite3.Error): raise HTTPException( status_code=503, detail="Later synchronization is unavailable", headers={"Retry-After": "1"}, ) @app.delete("/api/v1/session") async def sign_out(request: Request, response: Response): session = request.state.dashboard_session try: push_device_id = await dashboard_auth.session_management_id(session) except (dashboard_auth.SessionStoreError, AttributeError): push_device_id = None journal = _security_event_store() try: operation_id = await asyncio.to_thread( journal.reserve, "sign_out", target="current_device", ) except SecurityEventStoreError: return JSONResponse( {"detail": "Security activity is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) try: await asyncio.to_thread(dashboard_auth.revoke_session, session) except dashboard_auth.SessionStoreError: try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: # The pending reservation is durable evidence; do not report a # successful revocation as failed merely because completion could not # be marked yet. pass if push_device_id is not None: await asyncio.to_thread( _push_subscription_store.delete_session, push_device_id ) path = dashboard_auth.cookie_path(request) response.delete_cookie( dashboard_auth.SESSION_COOKIE, path=path, secure=True, httponly=True, samesite="strict", ) response.delete_cookie( dashboard_auth.CSRF_COOKIE, path=path, secure=True, httponly=False, samesite="strict", ) response.headers["Cache-Control"] = "no-store" return {"authenticated": False, "clear_private_device_data": True} @app.get("/api/v1/sessions") async def list_active_devices(request: Request, response: Response): try: devices = await dashboard_auth.active_devices(request.state.dashboard_session) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) response.headers["Cache-Control"] = "no-store" return { "devices": [ { "management_id": device.management_id, "device_label": device.device_label, "created_at": device.created_at, "expires_at": device.expires_at, "current": device.current, } for device in devices ] } @app.delete("/api/v1/sessions/{management_id}") async def revoke_active_device( request: Request, management_id: str = PathParam( min_length=16, max_length=64, pattern=r"^[A-Za-z0-9_-]+$" ), step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): await _require_step_up( request, step_up_grant, action="revoke_device", target=management_id, ) try: devices = await dashboard_auth.active_devices(request.state.dashboard_session) target = next( (device for device in devices if device.management_id == management_id), None ) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if target is None: raise HTTPException(status_code=404, detail="Active device not found") journal = _security_event_store() try: operation_id = await asyncio.to_thread( journal.reserve, "device_revoked", device_label=target.device_label, target="device", ) except SecurityEventStoreError: return JSONResponse( {"detail": "Security activity is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) try: revoked = await asyncio.to_thread( _passkey_store().revoke_device_access, management_id ) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) if not revoked: raise HTTPException(status_code=404, detail="Active device not found") await asyncio.to_thread( _push_subscription_store.delete_session, management_id ) try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: pass return {"revoked": True, "current_session": target.current} @app.delete("/api/v1/sessions") async def sign_out_all_devices( request: Request, response: Response, step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): await _require_step_up( request, step_up_grant, action="revoke_all_sessions", target="all", ) journal = _security_event_store() try: operation_id = await asyncio.to_thread( journal.reserve, "all_sessions_revoked", target="all_devices", ) except SecurityEventStoreError: return JSONResponse( {"detail": "Security activity is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) try: await asyncio.to_thread(_passkey_store().revoke_all_access) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) await asyncio.to_thread(_push_subscription_store.delete_all) try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: pass path = dashboard_auth.cookie_path(request) response.delete_cookie( dashboard_auth.SESSION_COOKIE, path=path, secure=True, httponly=True, samesite="strict", ) response.delete_cookie( dashboard_auth.CSRF_COOKIE, path=path, secure=True, httponly=False, samesite="strict", ) response.headers["Cache-Control"] = "no-store" return { "authenticated": False, "all_sessions_revoked": True, "clear_private_device_data": True, } @app.get("/readyz") async def readiness(): """Return the latest readiness snapshot without contacting Gitea.""" state = _readiness_state if state is None or time.monotonic() - state.checked_at > READINESS_MAX_AGE_SECONDS: return JSONResponse( { "status": "not_ready", "service": "stackchain-dashboard", "error": "Gitea readiness status is not available", }, status_code=503, ) if not state.ready: return JSONResponse( { "status": "not_ready", "service": "stackchain-dashboard", "error": state.error, }, status_code=503, headers={ "Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS))) } if state.timed_out else None, ) payload = { "status": "ready", "service": "stackchain-dashboard", } if not dashboard_auth.enabled(): payload["gitea_user"] = state.login return payload @app.get("/api/v1/context") async def context() -> JSONResponse: try: user_data, repo_data, issues_data, prs_data = await asyncio.wait_for( asyncio.gather(current_user(), repos(), issues(), pull_requests()), timeout=CONTEXT_TIMEOUT_SECONDS, ) if not isinstance(user_data, dict): raise ContextPayloadError("Gitea current-user response was not an object") if not all(field in user_data for field in ("id", "login")): raise ContextPayloadError( "Gitea current-user response did not include id and login" ) if any( data is not None and not isinstance(data, list) for data in (repo_data, issues_data, prs_data) ): raise ContextPayloadError("Gitea collection response was not a list") except Exception as e: if isinstance(e, TimeoutError): error_message = ( f"Gitea context request timed out after {CONTEXT_TIMEOUT_SECONDS:g}s" ) elif isinstance(e, ContextPayloadError): error_message = str(e) else: error_message = "Gitea context is temporarily unavailable" return JSONResponse({ "user": {"id": None, "login": "timmy", "full_name": "Timmy Jr", "email": ""}, "repos": [], "issues": [], "pull_requests": [], "view": "dashboard", "deltas": [ {"panel": "auth", "action": "connect", "target": "stackchain-dashboard backend", "priority": "high"}, {"panel": "gitea", "action": "set GITEA_URL + GITEA_TOKEN", "target": "/root/stackchain-dashboard/.env or systemd env", "priority": "high"}, ], "error": error_message, }, headers={ "Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS))) } if isinstance(e, TimeoutError) else None) return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data)) @app.get("/api/v1/repositories/search") async def repository_search( q: str = Query(min_length=2, max_length=80), limit: int = Query(default=20, ge=1, le=20), ) -> JSONResponse: """Return minimal repository identities matching a bounded visible-repo search.""" query = q.strip() if len(query) < 2: return JSONResponse({"error": "Enter at least 2 search characters."}, status_code=400) try: matches = await asyncio.wait_for( gitea_proxy.search_repositories(query, limit), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except Exception: return JSONResponse( {"error": "Repository search is temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) items = [ {"id": item["id"], "name": item["name"], "full_name": item["full_name"]} for item in matches if isinstance(item, dict) and all(field in item for field in ("id", "name", "full_name")) ] return JSONResponse({"items": items}, headers={"Cache-Control": "no-store"}) @app.get("/api/v1/repositories") async def repository_page( page: int = Query(default=1, ge=1, le=1000), limit: int = Query(default=50, ge=1, le=50), ) -> JSONResponse: """Return one bounded repository page for lazy issue-capture selection.""" try: result = await asyncio.wait_for( gitea_proxy.repo_page(page=page, limit=limit), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) items = [ Repo( id=item["id"], name=item["name"], full_name=item["full_name"], description=item.get("description") or "", url=item["html_url"], updated_at=item.get("updated_at", ""), ).model_dump() for item in result.get("items", []) if isinstance(item, dict) and all(field in item for field in ("id", "name", "full_name", "html_url")) ] return JSONResponse({**result, "items": items}) except Exception: return JSONResponse( {"error": "Repositories could not be loaded. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.get("/api/v1/background-identity") async def background_identity() -> JSONResponse: """Return only the account key required to safely drain a browser outbox.""" try: user = await current_user() login = user.get("login") if isinstance(user, dict) else None if not isinstance(login, str) or not login.strip(): raise ValueError("Gitea user response did not include a login") except Exception: return JSONResponse( {"error": "Gitea identity is temporarily unavailable"}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse({"login": login}) @app.get("/api/v1/search") async def global_search( q: str = Query(min_length=2, max_length=100), limit: int = Query(default=10, ge=1, le=25), page: int = Query(default=1, ge=1, le=100), kind: Literal["all", "issue", "pull"] = Query(default="all"), state: Literal["all", "open", "closed"] = Query(default="all"), repository: str | None = Query( default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", max_length=161 ), ) -> JSONResponse: query = q.strip() if len(query) < 2: raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters") try: arguments = (query, limit, page, kind, state) operation = gitea_proxy.global_search(*arguments, repository) if repository else ( gitea_proxy.global_search(*arguments) ) result = await asyncio.wait_for( operation, timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, ) except Exception: return JSONResponse( {"error": "Search is temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) scope = {"kind": kind, "state": state} if repository: scope["repository"] = repository return JSONResponse({"query": query, "scope": scope, **result}) @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview") async def global_search_preview( owner: str, repo: str, number: int = PathParam(gt=0), kind: Literal["issue", "pull"] = Query(), ) -> JSONResponse: try: preview = await asyncio.wait_for( gitea_proxy.work_preview(f"{owner}/{repo}", kind, number), timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, ) except Exception: return JSONResponse( {"error": "This work item is temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(preview) @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation") async def global_search_preview_conversation( owner: str, repo: str, number: int = PathParam(gt=0), kind: Literal["issue", "pull"] = Query(), page: int | None = Query(default=None, ge=1, le=100), limit: int = Query(default=20, ge=1, le=50), ) -> JSONResponse: del kind # Issue and pull-request conversations share Gitea's issue-comments API. try: conversation = await asyncio.wait_for( gitea_proxy.issue_conversation_page( f"{owner}/{repo}", number, page, limit ), timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, ) except Exception: return JSONResponse( {"error": "This conversation is temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(conversation) @app.post( "/api/v1/repos/{owner}/{repo}/issues/{number}/preview/comments", status_code=201, ) async def comment_on_global_search_preview( comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0), kind: Literal["issue", "pull"] = Query(), idempotency_key: str | None = Header(default=None, max_length=128), ) -> JSONResponse: repository = f"{owner}/{repo}" async def post_comment(): preview = await gitea_proxy.work_preview(repository, kind, number) if ( preview.get("repository") != repository or preview.get("kind") != kind or preview.get("number") != number ): raise HTTPException(status_code=404, detail="Search result not found") return await gitea_proxy.comment_on_issue(repository, number, comment.body) try: result = await _run_idempotent_authored_action( post_comment(), idempotency_key=idempotency_key, fingerprint=("search-preview-comment", repository, kind, number, comment.body), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except HTTPException: raise except Exception: return JSONResponse( {"error": "The reply could not be posted. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, status_code=201) @app.get("/api/v1/work-route") async def resolve_work_route( kind: Literal["issue", "pull", "review", "update"] = Query(), repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"), number: int | None = Query(default=None, gt=0), notification_id: int | None = Query(default=None, gt=0), ) -> JSONResponse: repository_route = repository is not None and number is not None and notification_id is None update_route = kind == "update" and notification_id is not None and repository is None and number is None if (kind == "update" and not update_route) or (kind != "update" and not repository_route): raise HTTPException(status_code=422, detail="Invalid work route identity") try: result = await asyncio.wait_for( gitea_proxy.resolve_work_route(kind, repository, number, notification_id), timeout=WORK_PAGE_TIMEOUT_SECONDS, ) except gitea_proxy.WorkRouteUnavailableError: raise HTTPException(status_code=404, detail="Work item is no longer in My Work") except Exception: return JSONResponse( {"error": "The shared work item is temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.get("/api/v1/work/{stream}") async def paged_work( stream: Literal["issue", "pull", "review"], page: int = Query(ge=2, le=100), ) -> JSONResponse: try: result = await asyncio.wait_for( gitea_proxy.work_page(stream, page), timeout=WORK_PAGE_TIMEOUT_SECONDS ) except Exception: return JSONResponse( {"error": "Work page is temporarily unavailable"}, status_code=503, headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))}, ) return JSONResponse({ "stream": stream, "page": result["page"], "total": result["total"], "has_more": result["has_more"], "items": _normalize_work_items(stream, result["items"]), }) async def _refresh_available_issue_snapshot(lease_owner: str) -> list[dict]: global _available_issue_snapshot_value, _available_issue_snapshot_created_at global _available_issue_snapshot_retry_at try: result = await gitea_proxy.available_issue_snapshot() shared = await asyncio.to_thread( _available_issue_snapshot_store.publish, lease_owner, items=result ) _available_issue_snapshot_value = shared.items _available_issue_snapshot_created_at = time.monotonic() _available_issue_snapshot_retry_at = None return shared.items or [] except asyncio.CancelledError: await asyncio.to_thread( _available_issue_snapshot_store.release_refresh, lease_owner ) raise except Exception: try: await asyncio.to_thread( _available_issue_snapshot_store.fail_refresh, lease_owner, retry_at=time.time() + AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS, ) except Exception: pass raise def _observe_available_issue_refresh(task: asyncio.Task) -> None: global _available_issue_snapshot_retry_at if not task.cancelled(): if task.exception() is not None: _available_issue_snapshot_retry_at = ( time.monotonic() + AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS ) async def _start_available_issue_refresh() -> bool: """Atomically start this worker's refresh when the shared lease is available.""" global _available_issue_snapshot_task async with _available_issue_snapshot_lock: if ( _available_issue_snapshot_task is not None and not _available_issue_snapshot_task.done() ): return True lease_owner = await asyncio.to_thread( _available_issue_snapshot_store.try_acquire_refresh, lease_seconds=AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS, ) if lease_owner is None: return False _available_issue_snapshot_task = asyncio.create_task( _refresh_available_issue_snapshot(lease_owner) ) _available_issue_snapshot_task.add_done_callback(_observe_available_issue_refresh) return True async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]: global _available_issue_snapshot_task global _available_issue_snapshot_value, _available_issue_snapshot_created_at shared = await asyncio.to_thread(_available_issue_snapshot_store.load) wall_now = time.time() if shared.items is not None: _available_issue_snapshot_value = shared.items if ( shared.created_at is not None and wall_now - shared.created_at < AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS ): _available_issue_snapshot_created_at = time.monotonic() return shared.items, False, False, False if shared.retry_at is not None and wall_now < shared.retry_at: return shared.items, True, False, True now = time.monotonic() if ( _available_issue_snapshot_value is not None and _available_issue_snapshot_created_at is not None and now - _available_issue_snapshot_created_at < AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS ): return _available_issue_snapshot_value, False, False, False if ( _available_issue_snapshot_value is not None and _available_issue_snapshot_retry_at is not None and now < _available_issue_snapshot_retry_at ): return _available_issue_snapshot_value, True, False, True local_refresh = await _start_available_issue_refresh() if _available_issue_snapshot_value is not None: return _available_issue_snapshot_value, True, True, False if not local_refresh: wait_deadline = time.monotonic() + WORK_PAGE_TIMEOUT_SECONDS while time.monotonic() < wait_deadline: await asyncio.sleep(0.02) shared = await asyncio.to_thread(_available_issue_snapshot_store.load) if shared.items is not None: return shared.items, False, False, False if not shared.refreshing: local_refresh = await _start_available_issue_refresh() if local_refresh: break if not local_refresh: raise RuntimeError("available issue catalog refresh is owned by another worker") try: return await asyncio.shield(_available_issue_snapshot_task), False, False, False except Exception: if _available_issue_snapshot_value is not None: return _available_issue_snapshot_value, True, False, True raise def _available_issue_page( items: list[dict], page: int, limit: int = 50, query: str = "", repositories: list[str] | None = None, labels: list[str] | None = None, include_facets: bool = False, ) -> dict: facets = { "repositories": sorted({str(item.get("repository")) for item in items if item.get("repository")}, key=str.casefold), "labels": sorted({str(label) for item in items for label in (item.get("labels") or [])}, key=str.casefold), } repository_filter = {value.casefold() for value in (repositories or [])} label_filter = {value.casefold() for value in (labels or [])} if repository_filter: items = [item for item in items if str(item.get("repository") or "").casefold() in repository_filter] if label_filter: items = [item for item in items if label_filter & {str(label).casefold() for label in (item.get("labels") or [])}] normalized_query = query.strip().casefold() if normalized_query: number_query = normalized_query.removeprefix("#") items = [ item for item in items if normalized_query in str(item.get("repository") or "").casefold() or normalized_query in str(item.get("title") or "").casefold() or (number_query.isdigit() and number_query == str(item.get("number") or "")) ] start = (page - 1) * limit page_items = items[start:start + limit] result = { "items": page_items, "page": page, "total": len(items), "has_more": start + len(page_items) < len(items), } if include_facets or repository_filter or label_filter: result["facets"] = facets return result @app.get("/api/v1/available-issues") async def available_issues( page: int = Query(default=1, ge=1, le=100), q: str = Query(default="", max_length=100), repository: list[str] = Query(default=[], max_length=100), label: list[str] = Query(default=[], max_length=100), facets: bool = Query(default=False), ) -> JSONResponse: if len(repository) > 10 or len(label) > 10: return JSONResponse({"error": "Select at most 10 repositories or labels."}, status_code=422) try: items, stale, revalidating, refresh_failed = await asyncio.wait_for( _available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS ) except Exception: return JSONResponse( {"error": "Available issues are temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))}, ) result = _available_issue_page( items, page, query=q, repositories=repository, labels=label, include_facets=facets ) if stale: result["stale"] = True if revalidating: result["revalidating"] = True if refresh_failed: result["refresh_failed"] = True return JSONResponse(result) async def _load_context_for_user(user_data: dict) -> dict: repo_data, issues_data, prs_data = await asyncio.gather( repos(), issues(), pull_requests() ) if not all(field in user_data for field in ("id", "login")): raise ContextPayloadError("Gitea current-user response did not include id and login") if any( data is not None and not isinstance(data, list) for data in (repo_data, issues_data, prs_data) ): raise ContextPayloadError("Gitea collection response was not a list") return _context_payload(user_data, repo_data, issues_data, prs_data) async def _build_live_snapshot(sections: set[str] | None = None) -> dict: requested = set(sections or LIVE_SNAPSHOT_SECTIONS) results: dict[str, object] = {} user_data: dict | None = None if requested & {"context", "events"}: try: user_data = await current_user() if not isinstance(user_data, dict) or not user_data.get("login"): raise ContextPayloadError("Gitea current-user response was invalid") except Exception as exc: for section in requested & {"context", "events"}: results[section] = exc loads: dict[str, Awaitable[Any]] = {} if "context" in requested and "context" not in results: assert user_data is not None loads["context"] = _load_context_for_user(user_data) if "events" in requested and "events" not in results: assert user_data is not None loads["events"] = activity_events(user_data) if "notifications" in requested: loads["notifications"] = notifications() if loads: loaded = await asyncio.gather(*loads.values(), return_exceptions=True) results.update(zip(loads, loaded)) context_result = results.get("context") events_result = results.get("events") notifications_result = results.get("notifications") context_ok = "context" in requested and not isinstance(context_result, BaseException) events_ok = "events" in requested and not isinstance(events_result, BaseException) notifications_ok = ( "notifications" in requested and not isinstance(notifications_result, BaseException) ) notification_items = notifications_result notification_pagination = None if notifications_ok and isinstance(notifications_result, dict): notification_items = notifications_result.get("items") notification_pagination = { "page": notifications_result.get("page", 1), "total": notifications_result.get("total", 0), "has_more": notifications_result.get("has_more") is True, } notifications_ok = isinstance(notification_items, list) return { "context": context_result if context_ok else None, "events": events_result if events_ok else None, "notifications": notification_items if notifications_ok else None, "notification_pagination": notification_pagination if notifications_ok else None, "sections": { section: "fresh" if ok else "temporarily unavailable" for section, ok in ( ("context", context_ok), ("events", events_ok), ("notifications", notifications_ok), ) if section in requested }, } async def _build_live_snapshot_before_deadline(sections: set[str]) -> dict: async with asyncio.timeout(CONTEXT_TIMEOUT_SECONDS): return await _build_live_snapshot(sections) def _record_live_section_failure(section: str) -> None: _live_section_failure_count[section] += 1 delay = min( LIVE_SNAPSHOT_RETRY_MAX_SECONDS, LIVE_SNAPSHOT_RETRY_BASE_SECONDS * (2 ** (_live_section_failure_count[section] - 1)), ) _live_section_retry_at[section] = _live_snapshot_clock() + delay def _due_live_sections(now: float) -> set[str]: due = set() for section in LIVE_SNAPSHOT_SECTIONS: retry_at = _live_section_retry_at[section] if retry_at is not None and now < retry_at: continue created_at = _live_section_created_at[section] if created_at is None or now - created_at >= LIVE_SNAPSHOT_FRESHNESS_SECONDS: due.add(section) return due def _apply_shared_live_state(state: LiveSnapshotState) -> None: """Replace this worker's fast local view with one coherent SQLite read.""" global _live_snapshot_value, _live_snapshot_created_at global _live_section_created_at, _live_section_failure_count, _live_section_retry_at global _live_section_revisions, _live_revision_generation global _live_snapshot_refreshing_sections _live_snapshot_value = state.value _live_section_created_at = state.created_at _live_section_failure_count = state.failure_count _live_section_retry_at = state.retry_at _live_section_revisions = state.revisions _live_revision_generation = state.generation _live_snapshot_refreshing_sections = state.refreshing_sections successful = [value for value in state.created_at.values() if value is not None] _live_snapshot_created_at = max(successful) if successful else None def _apply_shared_live_metadata(state: LiveSnapshotMetadata) -> None: """Update coordination state while retaining the matching local snapshot value.""" global _live_snapshot_created_at global _live_section_created_at, _live_section_failure_count, _live_section_retry_at global _live_section_revisions, _live_revision_generation global _live_snapshot_refreshing_sections _live_section_created_at = state.created_at _live_section_failure_count = state.failure_count _live_section_retry_at = state.retry_at _live_section_revisions = state.revisions _live_revision_generation = state.generation _live_snapshot_refreshing_sections = state.refreshing_sections successful = [value for value in state.created_at.values() if value is not None] _live_snapshot_created_at = max(successful) if successful else None async def _sync_shared_live_state() -> None: """Load the large shared value only when this worker's copy is out of date.""" metadata = await asyncio.to_thread(_live_snapshot_store.load_metadata) if ( _live_snapshot_value is None or metadata.generation != _live_revision_generation or metadata.revisions != _live_section_revisions ): _apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load)) else: _apply_shared_live_metadata(metadata) def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict: if previous is None: return refreshed merged = dict(previous) sections = dict(previous.get("sections") or {}) for section, state in (refreshed.get("sections") or {}).items(): if state == "fresh": merged[section] = refreshed.get(section) sections[section] = "fresh" if section == "notifications": merged["notification_pagination"] = refreshed.get( "notification_pagination" ) elif previous.get(section) is not None: sections[section] = "stale" else: merged[section] = None sections[section] = "temporarily unavailable" merged["sections"] = sections return merged async def _refresh_live_snapshot(sections: set[str], lease_owner: str) -> dict: global _live_snapshot_value, _live_snapshot_created_at try: refreshed = await _build_live_snapshot_before_deadline(sections) except asyncio.CancelledError: try: await asyncio.shield( asyncio.to_thread(_live_snapshot_store.release_refresh, lease_owner) ) finally: raise except Exception: for section in sections: _record_live_section_failure(section) try: state = await asyncio.to_thread( _live_snapshot_store.fail_refresh, lease_owner, failure_count=_live_section_failure_count, retry_at=_live_section_retry_at, ) _apply_shared_live_state(state) except RefreshLeaseLost: _apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load)) raise now = _live_snapshot_clock() refreshed_states = refreshed.get("sections") or {} for section in sections: if refreshed_states.get(section) == "fresh": _live_section_created_at[section] = now _live_section_failure_count[section] = 0 _live_section_retry_at[section] = None else: _record_live_section_failure(section) previous = _live_snapshot_value result = _merge_live_snapshot(previous, refreshed) result = _without_read_notifications(result) changed_sections = set() for section in sections: if section not in refreshed_states: continue changed = previous is None or previous.get(section) != result.get(section) if section == "notifications": changed = changed or previous is None or previous.get( "notification_pagination" ) != result.get("notification_pagination") if changed: changed_sections.add(section) try: state = await asyncio.to_thread( _live_snapshot_store.publish_refresh, lease_owner, value=result, created_at=_live_section_created_at, failure_count=_live_section_failure_count, retry_at=_live_section_retry_at, changed_sections=changed_sections, ) except RefreshLeaseLost: state = await asyncio.to_thread(_live_snapshot_store.load) if state.value is None: raise _apply_shared_live_state(state) assert state.value is not None return state.value def _consume_live_snapshot_failure(task: asyncio.Task) -> None: if task.cancelled(): return task.exception() def _start_live_snapshot_refresh( sections: set[str], lease_owner: str | None = None ) -> asyncio.Task: global _live_snapshot_task global _live_snapshot_refreshing_sections if _live_snapshot_task is None or _live_snapshot_task.done(): if lease_owner is None: lease_owner = _live_snapshot_store.try_acquire_refresh( sections, lease_seconds=CONTEXT_TIMEOUT_SECONDS + 1.0 ) if lease_owner is None: raise RuntimeError("live snapshot refresh lease is already held") _live_snapshot_refreshing_sections = set(sections) _live_snapshot_task = asyncio.create_task( _refresh_live_snapshot(sections, lease_owner) ) _live_snapshot_task.add_done_callback(_consume_live_snapshot_failure) return _live_snapshot_task def _live_snapshot_payload( value: dict, *, stale: bool, revalidating: bool, known_revisions: dict[str, str | None] | None = None, ) -> dict: payload = dict(value) now = _live_snapshot_clock() section_freshness = {} for section in LIVE_SNAPSHOT_SECTIONS: created_at = _live_section_created_at[section] retry_at = _live_section_retry_at[section] age = max(0.0, now - created_at) if created_at is not None else 0.0 retry_in = max(0.0, retry_at - now) if retry_at is not None else 0.0 section_freshness[section] = { "age_seconds": round(age, 3), "stale": (value.get("sections") or {}).get(section) != "fresh" or created_at is None or age >= LIVE_SNAPSHOT_FRESHNESS_SECONDS, "revalidating": revalidating and section in _live_snapshot_refreshing_sections, "degraded": retry_at is not None, "retry_in_seconds": math.ceil(retry_in), } ages = [item["age_seconds"] for item in section_freshness.values()] retries = [item["retry_in_seconds"] for item in section_freshness.values() if item["retry_in_seconds"]] degraded = any(item["degraded"] for item in section_freshness.values()) payload["freshness"] = { "age_seconds": max(ages, default=0.0), "fresh_for_seconds": LIVE_SNAPSHOT_FRESHNESS_SECONDS, "stale": stale or any(item["stale"] for item in section_freshness.values()), "revalidating": revalidating, "degraded": degraded, "last_refresh_failed": degraded, "retry_in_seconds": min(retries, default=0), "sections": section_freshness, } revision_tokens = { section: f"{_live_revision_generation}.{revision}" for section, revision in _live_section_revisions.items() } payload["revisions"] = revision_tokens for section, known_revision in (known_revisions or {}).items(): if known_revision is None or known_revision != revision_tokens[section]: continue payload.pop(section, None) if section == "notifications": payload.pop("notification_pagination", None) return payload async def _remove_notifications_from_live_snapshot(thread_ids: list[int]) -> None: global _live_snapshot_value, _read_notification_ids read_ids = set(thread_ids) if not read_ids: return _read_notification_ids = _read_notification_ids | read_ids if _live_snapshot_value is not None: retained_notifications = _live_snapshot_value.get("notifications") if isinstance(retained_notifications, list): updated = dict(_live_snapshot_value) updated["notifications"] = [ notification for notification in retained_notifications if not isinstance(notification, dict) or notification.get("id") not in read_ids ] if updated["notifications"] != retained_notifications: _live_section_revisions["notifications"] += 1 _live_snapshot_value = updated try: await asyncio.to_thread(_live_snapshot_store.remove_notifications, read_ids) except (OSError, sqlite3.Error): # The upstream mutation already succeeded; keep process-local filtering. pass def _without_read_notifications(snapshot: dict) -> dict: global _read_notification_ids snapshot_notifications = snapshot.get("notifications") if not isinstance(snapshot_notifications, list): return snapshot returned_ids = { notification.get("id") for notification in snapshot_notifications if isinstance(notification, dict) } updated = dict(snapshot) updated["notifications"] = [ notification for notification in snapshot_notifications if not isinstance(notification, dict) or notification.get("id") not in _read_notification_ids ] _read_notification_ids = _read_notification_ids.intersection(returned_ids) return updated async def _live_snapshot_response( context_revision: str | None = Query( default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$" ), events_revision: str | None = Query( default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$" ), notifications_revision: str | None = Query( default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$" ), ) -> JSONResponse: """Return a freshness-bounded snapshot and share identical upstream loads.""" global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at known_revisions = { "context": context_revision, "events": events_revision, "notifications": notifications_revision, } await _sync_shared_live_state() now = _live_snapshot_clock() due_sections = _due_live_sections(now) if _live_snapshot_value is not None and not due_sections: return JSONResponse( _live_snapshot_payload( _live_snapshot_value, stale=any( state != "fresh" for state in _live_snapshot_value.get("sections", {}).values() ), revalidating=False, known_revisions=known_revisions, ) ) if not due_sections: retries = [ retry_at - now for retry_at in _live_section_retry_at.values() if retry_at is not None and retry_at > now ] return JSONResponse( {"error": "Gitea live snapshot is temporarily unavailable"}, status_code=503, headers={"Retry-After": str(max(1, math.ceil(min(retries, default=1.0))))}, ) task = _live_snapshot_task if task is None or task.done(): lease_owner = await asyncio.to_thread( _live_snapshot_store.try_acquire_refresh, due_sections, lease_seconds=CONTEXT_TIMEOUT_SECONDS + 1.0, ) if lease_owner is not None: latest = await asyncio.to_thread(_live_snapshot_store.load) _apply_shared_live_state(latest) due_sections = _due_live_sections(_live_snapshot_clock()) if not due_sections: await asyncio.to_thread( _live_snapshot_store.release_refresh, lease_owner ) return JSONResponse( _live_snapshot_payload( _live_snapshot_value, stale=any( state != "fresh" for state in (_live_snapshot_value or {}).get("sections", {}).values() ), revalidating=False, known_revisions=known_revisions, ) ) task = _start_live_snapshot_refresh(due_sections, lease_owner) else: shared = await asyncio.to_thread(_live_snapshot_store.load) _apply_shared_live_state(shared) if _live_snapshot_value is not None: return JSONResponse( _live_snapshot_payload( _live_snapshot_value, stale=bool(_due_live_sections(_live_snapshot_clock())), revalidating=bool(shared.refreshing_sections), known_revisions=known_revisions, ) ) deadline = time.perf_counter() + CONTEXT_TIMEOUT_SECONDS while time.perf_counter() < deadline: await asyncio.sleep(0.01) shared = await asyncio.to_thread(_live_snapshot_store.load) if shared.value is not None: _apply_shared_live_state(shared) return JSONResponse( _live_snapshot_payload( shared.value, stale=bool(_due_live_sections(_live_snapshot_clock())), revalidating=bool(shared.refreshing_sections), known_revisions=known_revisions, ) ) return JSONResponse( {"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"}, status_code=503, headers={"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))}, ) assert task is not None if _live_snapshot_value is not None: return JSONResponse( _live_snapshot_payload( _live_snapshot_value, stale=True, revalidating=True, known_revisions=known_revisions, ) ) try: result = await asyncio.shield(task) return JSONResponse( _live_snapshot_payload( result, stale=False, revalidating=False, known_revisions=known_revisions, ) ) except TimeoutError: return JSONResponse( {"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"}, status_code=503, headers={"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))}, ) except Exception: return JSONResponse( {"error": "Gitea live snapshot is temporarily unavailable"}, status_code=503, ) @app.get("/api/v1/live") async def live_snapshot( context_revision: str | None = Query( default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$" ), events_revision: str | None = Query( default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$" ), notifications_revision: str | None = Query( default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$" ), ) -> JSONResponse: try: return await _live_snapshot_response( context_revision=context_revision, events_revision=events_revision, notifications_revision=notifications_revision, ) except (OSError, sqlite3.Error): return JSONResponse( {"error": "Gitea live snapshot state is temporarily unavailable"}, status_code=503, headers={"Retry-After": "1"}, ) @app.get("/api/v1/events") async def event_stream(): try: return await asyncio.wait_for( activity_events(), timeout=EVENT_STREAM_TIMEOUT_SECONDS ) except TimeoutError: return JSONResponse( { "error": ( "Gitea event stream request timed out after " f"{EVENT_STREAM_TIMEOUT_SECONDS:g}s" ) }, status_code=503, headers={ "Retry-After": str( max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS)) ) }, ) except Exception: return JSONResponse( {"error": "Gitea event stream is temporarily unavailable"}, status_code=503, headers={ "Retry-After": str( max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS)) ) }, ) async def _mark_notification_read_result(thread_id: int) -> tuple[int, bool]: try: await asyncio.wait_for( mark_notification_read(thread_id), timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, ) except Exception: return thread_id, False return thread_id, True @app.patch("/api/v1/notifications/read") async def read_notifications(batch: NotificationReadBatch) -> JSONResponse: thread_ids = list(dict.fromkeys(batch.ids)) semaphore = asyncio.Semaphore(BULK_NOTIFICATION_CONCURRENCY) async def mark_within_limit(thread_id: int) -> tuple[int, bool]: async with semaphore: return await _mark_notification_read_result(thread_id) tasks = [asyncio.create_task(mark_within_limit(thread_id)) for thread_id in thread_ids] done, pending = await asyncio.wait( tasks, timeout=BULK_NOTIFICATION_DEADLINE_SECONDS ) for task in pending: task.cancel() if pending: await asyncio.gather(*pending, return_exceptions=True) succeeded_ids = { thread_id for task in done if not task.cancelled() and task.exception() is None for thread_id, succeeded in [task.result()] if succeeded } await _remove_notifications_from_live_snapshot( [thread_id for thread_id in thread_ids if thread_id in succeeded_ids] ) failed = [thread_id for thread_id in thread_ids if thread_id not in succeeded_ids] return JSONResponse( { "marked": [thread_id for thread_id in thread_ids if thread_id in succeeded_ids], "failed": failed, }, headers={"Retry-After": "1"} if failed else None, ) @app.get("/api/v1/notifications") async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse: try: result = await asyncio.wait_for( gitea_proxy.notification_page(page), timeout=NOTIFICATION_PAGE_TIMEOUT_SECONDS, ) except TimeoutError: return JSONResponse( {"error": "Unread updates timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "Unread updates are temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.get("/api/v1/notifications/snapshot") async def notification_snapshot() -> JSONResponse: try: result = await gitea_proxy.unread_notification_snapshot( deadline_seconds=NOTIFICATION_PAGE_TIMEOUT_SECONDS ) except Exception: return JSONResponse( {"error": "Complete unread updates are temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.get("/api/v1/notifications/{thread_id}") async def notification_thread_detail( thread_id: int = PathParam(gt=0), ) -> JSONResponse: try: result = await asyncio.wait_for( notification_detail(thread_id), timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS, ) except TimeoutError: return JSONResponse( {"error": "Loading the update timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "The update is temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.get("/api/v1/notifications/{thread_id}/conversation") async def notification_thread_conversation( thread_id: int = PathParam(gt=0), page: int | None = Query(default=None, ge=1), limit: int = Query(default=20, ge=1, le=50), ) -> JSONResponse: try: result = await asyncio.wait_for( gitea_proxy.notification_conversation_page(thread_id, page, limit), timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS, ) except TimeoutError: return JSONResponse( {"error": "Loading older messages timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "Older messages are temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.patch("/api/v1/notifications/{thread_id}/read") async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: try: await asyncio.wait_for( mark_notification_read(thread_id), timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, ) except TimeoutError: return JSONResponse( {"error": "Marking the update read timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "The update could not be marked read. Please retry."}, status_code=503, ) await _remove_notifications_from_live_snapshot([thread_id]) return JSONResponse({"id": thread_id, "status": "read"}) @app.patch("/api/v1/notifications/{thread_id}/unread") async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: try: await asyncio.wait_for( gitea_proxy.mark_notification_unread(thread_id), timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, ) except TimeoutError: return JSONResponse( {"error": "Restoring the update timed out. Retry Undo."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "The update could not be restored. Retry Undo."}, status_code=503, ) return JSONResponse({"id": thread_id, "status": "unread"}) @app.post("/api/v1/notifications/{thread_id}/mute") async def mute_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: try: await asyncio.wait_for( gitea_proxy.mute_notification(thread_id), timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, ) except TimeoutError: return JSONResponse( {"error": "Muting future updates timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "Future updates could not be muted. Please retry."}, status_code=503, ) try: await asyncio.wait_for( mark_notification_read(thread_id), timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, ) except Exception: return JSONResponse( { "id": thread_id, "muted": True, "status": "unread", "error": "Future updates are muted; current item is still unread. Retry mark read & next.", }, status_code=409, ) await _remove_notifications_from_live_snapshot([thread_id]) return JSONResponse({"id": thread_id, "muted": True, "status": "read"}) @app.patch("/api/v1/notifications/{thread_id}/later") async def defer_notification( payload: NotificationLaterRequest, thread_id: int = PathParam(gt=0), ) -> JSONResponse: login = await _confirmed_login() try: item = await asyncio.wait_for( gitea_proxy.resolve_work_route("update", None, None, thread_id), timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS, ) repository = item.get("repository") if not isinstance(repository, str) or not repository: raise ValueError("The update has no repository identity") item_id = f"update:{repository}::{thread_id}" result = await asyncio.to_thread( _later_store().apply, login, f"push-tomorrow:{thread_id}:{payload.wake_at}", "defer", item_id, wake_at=payload.wake_at, ) except gitea_proxy.WorkRouteUnavailableError: return JSONResponse( {"error": "This update is no longer available to defer."}, status_code=409, ) except ValueError as error: raise HTTPException(status_code=422, detail=str(error)) except TimeoutError: return JSONResponse( {"error": "Deferring the update timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except (OSError, sqlite3.Error): return JSONResponse( {"error": "Later synchronization is unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse({ "id": thread_id, "status": "deferred", "item_id": item_id, "wake_at": payload.wake_at, "revision": result["revision"], }) @app.post("/api/v1/notifications/{thread_id}/acknowledge") async def acknowledge_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: try: result = await asyncio.wait_for( gitea_proxy.acknowledge_notification(thread_id), timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, ) except TimeoutError: return JSONResponse( {"error": "Acknowledging the update timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except ValueError: return JSONResponse( {"error": "This update has no comment that can be acknowledged."}, status_code=422, ) except Exception: return JSONResponse( {"error": "The update could not be acknowledged. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) await _remove_notifications_from_live_snapshot([thread_id]) return JSONResponse(result) @app.post("/api/v1/notifications/{thread_id}/reply", status_code=201) async def reply_to_notification( reply: NotificationReply, thread_id: int = PathParam(gt=0), idempotency_key: str | None = Header(default=None, max_length=128), ) -> JSONResponse: try: result = await _run_idempotent_authored_action( gitea_proxy.reply_to_notification(thread_id, reply.body), idempotency_key=idempotency_key, fingerprint=("notification-reply", thread_id, reply.body), timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, ) except HTTPException: raise except Exception: return JSONResponse( { "error": ( "The reply could not be posted. Your draft is safe; please retry." ) }, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, status_code=201) @app.post("/api/v1/notifications/{thread_id}/attachments", status_code=201) async def attach_to_notification( request: Request, thread_id: int = PathParam(gt=0), idempotency_key: str | None = Header(default=None, max_length=128), ) -> JSONResponse: try: if request.headers.get("content-type", "").lower().startswith("multipart/form-data"): form = await request.form() uploaded = form.get("file") if not isinstance(uploaded, UploadFile): raise ValueError("screenshot file is required") filename = str(uploaded.filename or "") content_type = str(uploaded.content_type or "") content = _validate_binary_attachment(filename, content_type, await uploaded.read()) else: attachment = IssueAttachment.model_validate(await request.json()) filename = attachment.filename content_type = attachment.content_type content = attachment.content() except (ValueError, ValidationError) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc async def upload_attachment(): result = await gitea_proxy.upload_notification_attachment( thread_id, filename, content_type, content ) safe_name = ( result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") .replace("\r", " ").replace("\n", " ") ) safe_url = result["url"].replace("<", "%3C").replace(">", "%3E") result["markdown"] = f"![{safe_name}](<{safe_url}>)" return result try: result = await _run_idempotent_authored_action( upload_attachment(), idempotency_key=idempotency_key, fingerprint=("notification-attachment", thread_id, filename, content_type, hashlib.sha256(content).hexdigest()), timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS, ) except HTTPException: raise except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except Exception: return JSONResponse( {"error": "The screenshot could not be uploaded. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, status_code=201) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim") async def claim_available_issue( owner: str, repo: str, number: int = PathParam(gt=0) ) -> JSONResponse: global _available_issue_snapshot_value repository = f"{owner}/{repo}" try: result = await asyncio.wait_for( gitea_proxy.claim_available_issue(repository, number), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError: return JSONResponse( {"error": "This issue was already claimed or is no longer open."}, status_code=409, ) except Exception: return JSONResponse( {"error": "The issue could not be assigned. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) retained = _available_issue_snapshot_value shared = await asyncio.to_thread( _available_issue_snapshot_store.remove_claimed, repository, number ) if shared.items is not None: _available_issue_snapshot_value = shared.items elif retained is not None: _available_issue_snapshot_value = [ item for item in retained if item.get("repository") != repository or item.get("number") != number ] return JSONResponse(result) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/reopen") async def reopen_closed_issue( owner: str, repo: str, number: int = PathParam(gt=0) ) -> JSONResponse: repository = f"{owner}/{repo}" try: result = await asyncio.wait_for( gitea_proxy.reopen_issue(repository, number), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError: return JSONResponse( {"error": "This issue is no longer closed or cannot be resumed."}, status_code=409, ) except Exception: return JSONResponse( {"error": "The issue could not be reopened. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release") async def release_assigned_issue( owner: str, repo: str, number: int = PathParam(gt=0) ) -> JSONResponse: global _available_issue_snapshot_value, _available_issue_snapshot_created_at repository = f"{owner}/{repo}" try: result = await asyncio.wait_for( gitea_proxy.release_assigned_issue(repository, number), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError: raise HTTPException(status_code=404, detail="Assigned issue not found") except Exception: return JSONResponse( {"error": "The assignment could not be released. It remains in My Work; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) if result.get("available"): await asyncio.to_thread(_available_issue_snapshot_store.invalidate) _available_issue_snapshot_value = None _available_issue_snapshot_created_at = None return JSONResponse(result) @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates") async def issue_handoff_candidates( owner: str, repo: str, number: int = PathParam(gt=0) ) -> JSONResponse: repository = f"{owner}/{repo}" async def load_candidates(): if not await gitea_proxy.is_assigned_issue(repository, number): raise HTTPException(status_code=404, detail="Assigned issue not found") return await gitea_proxy.issue_handoff_candidates(repository) try: result = await asyncio.wait_for( load_candidates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS ) except HTTPException: raise except Exception: return JSONResponse( {"error": "Teammates could not be loaded. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.get("/api/v1/repos/{owner}/{repo}/mention-candidates") async def repository_mention_candidates( owner: str, repo: str, q: str = Query(min_length=2, max_length=39, pattern=r"^[A-Za-z0-9_.-]+$"), ) -> JSONResponse: try: result = await asyncio.wait_for( gitea_proxy.mention_candidates( f"{owner}/{repo}", q.casefold(), limit=8 ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except Exception: return JSONResponse( {"error": "Teammate suggestions are temporarily unavailable."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, headers={"Cache-Control": "no-store"}) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff") async def handoff_assigned_issue( handoff: IssueHandoff, owner: str, repo: str, number: int = PathParam(gt=0), ) -> JSONResponse: repository = f"{owner}/{repo}" try: result = await asyncio.wait_for( gitea_proxy.handoff_assigned_issue( repository, number, handoff.recipient ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError: return JSONResponse( {"error": "The issue or recipient changed. Reload before handing off."}, status_code=409, ) except Exception: return JSONResponse( {"error": "The handoff could not be confirmed. It remains in My Work; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail") async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)): repository = f"{owner}/{repo}" async def load_assigned_issue(): if not await gitea_proxy.is_assigned_issue(repository, number): raise HTTPException(status_code=404, detail="Assigned issue not found") return await gitea_proxy.issue_detail(repository, number) try: return await asyncio.wait_for( load_assigned_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS ) except HTTPException: raise except TimeoutError: return JSONResponse( {"error": "Loading the issue timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "The issue is temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) async def _mutate_assigned_issue_blocker( update: IssueBlockerUpdate, owner: str, repo: str, number: int, remove: bool, ) -> JSONResponse: try: result = await asyncio.wait_for( gitea_proxy.mutate_assigned_issue_dependency( f"{owner}/{repo}", number, update.repository, update.number, remove ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError: raise HTTPException(status_code=404, detail="Assigned issue not found") except gitea_proxy.IssueDependencyInvalidError as exc: return JSONResponse({"error": str(exc)}, status_code=422) except Exception: return JSONResponse( {"error": "The blocker change could not be confirmed. Reload the issue before retrying."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, headers={"Cache-Control": "no-store"}) @app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers") async def add_assigned_issue_blocker( update: IssueBlockerUpdate, owner: str, repo: str, number: int = PathParam(gt=0), ) -> JSONResponse: return await _mutate_assigned_issue_blocker(update, owner, repo, number, False) @app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers") async def remove_assigned_issue_blocker( update: IssueBlockerUpdate, owner: str, repo: str, number: int = PathParam(gt=0), ) -> JSONResponse: return await _mutate_assigned_issue_blocker(update, owner, repo, number, True) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers") async def set_assigned_issue_blocker_state( update: IssueBlockerDesiredState, owner: str, repo: str, number: int = PathParam(gt=0), ) -> JSONResponse: return await _mutate_assigned_issue_blocker( update, owner, repo, number, not update.present ) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content") async def update_assigned_issue_content( update: IssueContentUpdate, owner: str, repo: str, number: int = PathParam(gt=0), ): repository = f"{owner}/{repo}" try: result = await asyncio.wait_for( gitea_proxy.update_assigned_issue( repository, number, update.title, update.body, update.expected_updated_at, ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueEditConflictError: return JSONResponse( {"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."}, status_code=409, ) except gitea_proxy.IssueNotAvailableError: raise HTTPException(status_code=404, detail="Assigned issue not found") except HTTPException: raise except Exception: return JSONResponse( {"error": "The issue could not be updated. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/due-date") async def update_assigned_issue_due_date( update: IssueDueDateUpdate, owner: str, repo: str, number: int = PathParam(gt=0), ): repository = f"{owner}/{repo}" try: result = await asyncio.wait_for( gitea_proxy.update_assigned_issue_due_date( repository, number, update.due_date ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError: raise HTTPException(status_code=404, detail="Assigned issue not found") except Exception: return JSONResponse( {"error": "The due date could not be updated. Your selection is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/milestone") async def update_assigned_issue_milestone( update: IssueMilestoneUpdate, owner: str, repo: str, number: int = PathParam(gt=0), ): repository = f"{owner}/{repo}" try: result = await asyncio.wait_for( gitea_proxy.update_assigned_issue_milestone( repository, number, update.milestone_id ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError: raise HTTPException(status_code=404, detail="Assigned issue not found") except ValueError as exc: if str(exc) == "Unknown open repository milestone": raise HTTPException(status_code=422, detail=str(exc)) return JSONResponse( {"error": "The milestone could not be updated. Your selection is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "The milestone could not be updated. Your selection is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.get("/api/v1/repos/{owner}/{repo}/milestones") async def repository_milestones(owner: str, repo: str): repository = f"{owner}/{repo}" async def load_milestones(): if await gitea_proxy.repository_access(repository) is None: raise HTTPException(status_code=404, detail="Repository not found") return await gitea_proxy.repo_milestones(repository) try: return await asyncio.wait_for( load_milestones(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS ) except HTTPException: raise except Exception: return JSONResponse( {"error": "Milestones could not be loaded. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.get("/api/v1/repos/{owner}/{repo}/labels") async def repository_labels(owner: str, repo: str): repository = f"{owner}/{repo}" async def load_labels(): if await gitea_proxy.repository_access(repository) is None: raise HTTPException(status_code=404, detail="Repository not found") return await gitea_proxy.repo_labels(repository) try: return await asyncio.wait_for(load_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS) except HTTPException: raise except Exception: return JSONResponse( {"error": "Labels could not be loaded. Issue creation is still available."}, status_code=503, headers={"Retry-After": "1"}, ) @app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201) async def create_assigned_issue( creation: IssueCreation, owner: str, repo: str, idempotency_key: str | None = Header(default=None, max_length=128), ): repository = f"{owner}/{repo}" async def create_issue(): user, accessible = await asyncio.gather( gitea_proxy.current_user(), gitea_proxy.repository_access(repository) ) login = user.get("login") if isinstance(user, dict) else None if not login or accessible is None: raise HTTPException(status_code=404, detail="Repository not found") if creation.label_ids: available_labels = await gitea_proxy.repo_labels(repository) valid_label_ids = { item.get("id") for item in available_labels if isinstance(item, dict) and isinstance(item.get("id"), int) } if any(label_id not in valid_label_ids for label_id in creation.label_ids): raise HTTPException(status_code=422, detail="Unknown repository label") if creation.milestone_id is not None: available_milestones = await gitea_proxy.repo_milestones(repository) valid_milestone_ids = { item.get("id") for item in available_milestones if isinstance(item, dict) and isinstance(item.get("id"), int) } if creation.milestone_id not in valid_milestone_ids: raise HTTPException( status_code=422, detail="Unknown open repository milestone" ) if creation.milestone_id is not None or creation.due_date is not None: return await gitea_proxy.create_issue( repository, creation.title, creation.body, login, creation.label_ids, creation.milestone_id, creation.due_date, ) return await gitea_proxy.create_issue( repository, creation.title, creation.body, login, creation.label_ids ) try: result = await _run_idempotent_authored_action( create_issue(), idempotency_key=idempotency_key, fingerprint=( "issue-create", repository, creation.title, creation.body, tuple(creation.label_ids), creation.milestone_id, creation.due_date, ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except HTTPException: raise except Exception: return JSONResponse( {"error": "The issue could not be created. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, status_code=201) @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments") async def assigned_issue_conversation( owner: str, repo: str, number: int = PathParam(gt=0), page: int | None = Query(default=None, ge=1, le=100), limit: int = Query(default=20, ge=1, le=50), ): repository = f"{owner}/{repo}" async def load_conversation(): if not await gitea_proxy.is_assigned_issue(repository, number): raise HTTPException(status_code=404, detail="Assigned issue not found") return await gitea_proxy.issue_conversation_page(repository, number, page, limit) try: return await asyncio.wait_for( load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS ) except HTTPException: raise except Exception: return JSONResponse( {"error": "The conversation could not be loaded. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201) async def comment_on_assigned_issue( comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0), idempotency_key: str | None = Header(default=None, max_length=128), ): repository = f"{owner}/{repo}" async def post_comment(): if not await gitea_proxy.is_assigned_issue(repository, number): raise HTTPException(status_code=404, detail="Assigned issue not found") return await gitea_proxy.comment_on_issue(repository, number, comment.body) try: result = await _run_idempotent_authored_action( post_comment(), idempotency_key=idempotency_key, fingerprint=("issue-comment", repository, number, comment.body), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except HTTPException: raise except Exception: return JSONResponse( {"error": "The comment could not be posted. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, status_code=201) async def _edit_conversation_comment( repository: str, number: int, comment_id: int, body: str ) -> JSONResponse: try: result = await asyncio.wait_for( gitea_proxy.edit_owned_comment(repository, number, comment_id, body), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.CommentMutationForbiddenError as exc: raise HTTPException( status_code=403, detail="You can only change your own comments" ) from exc except HTTPException: raise except Exception: return JSONResponse( {"error": "The comment could not be updated. Your edit is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) async def _delete_conversation_comment( request: Request, step_up_grant: str | None, repository: str, number: int, comment_id: int, ) -> JSONResponse: target = f"{repository}#{number}:{comment_id}" await _require_step_up( request, step_up_grant, action="delete_comment", target=target, ) try: journal = _security_event_store() operation_id = await asyncio.to_thread( journal.reserve, "comment_deleted", target=target, ) except SecurityEventStoreError: return JSONResponse( {"error": "Security activity is temporarily unavailable. The comment was not deleted."}, status_code=503, headers={"Retry-After": "1"}, ) try: result = await asyncio.wait_for( gitea_proxy.delete_owned_comment(repository, number, comment_id), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.CommentMutationForbiddenError as exc: try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass raise HTTPException( status_code=403, detail="You can only change your own comments" ) from exc except HTTPException: try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass raise except Exception: try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass return JSONResponse( {"error": "The comment deletion could not be confirmed. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: # Upstream deletion is authoritative; pending evidence remains truthful. pass return JSONResponse(result) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}") async def edit_assigned_issue_comment( comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0), comment_id: int = PathParam(gt=0), ): repository = f"{owner}/{repo}" if not await gitea_proxy.is_assigned_issue(repository, number): raise HTTPException(status_code=404, detail="Assigned issue not found") return await _edit_conversation_comment(repository, number, comment_id, comment.body) @app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}") async def delete_assigned_issue_comment( request: Request, owner: str, repo: str, number: int = PathParam(gt=0), comment_id: int = PathParam(gt=0), step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): repository = f"{owner}/{repo}" if not await gitea_proxy.is_assigned_issue(repository, number): raise HTTPException(status_code=404, detail="Assigned issue not found") return await _delete_conversation_comment( request, step_up_grant, repository, number, comment_id ) @app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}") async def edit_assigned_pull_comment( comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0), comment_id: int = PathParam(gt=0), ): repository = f"{owner}/{repo}" if not await gitea_proxy.is_assigned_pull(repository, number): raise HTTPException(status_code=404, detail="Assigned pull request not found") return await _edit_conversation_comment(repository, number, comment_id, comment.body) @app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}") async def delete_assigned_pull_comment( request: Request, owner: str, repo: str, number: int = PathParam(gt=0), comment_id: int = PathParam(gt=0), step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): repository = f"{owner}/{repo}" if not await gitea_proxy.is_assigned_pull(repository, number): raise HTTPException(status_code=404, detail="Assigned pull request not found") return await _delete_conversation_comment( request, step_up_grant, repository, number, comment_id ) @app.patch("/api/v1/notifications/{thread_id}/comments/{comment_id}") async def edit_notification_comment( comment: IssueComment, thread_id: int = PathParam(gt=0), comment_id: int = PathParam(gt=0), ): try: repository, number = await gitea_proxy.notification_conversation_target(thread_id) except Exception as exc: raise HTTPException(status_code=404, detail="Notification conversation not found") from exc return await _edit_conversation_comment(repository, number, comment_id, comment.body) @app.delete("/api/v1/notifications/{thread_id}/comments/{comment_id}") async def delete_notification_comment( request: Request, thread_id: int = PathParam(gt=0), comment_id: int = PathParam(gt=0), step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): try: repository, number = await gitea_proxy.notification_conversation_target(thread_id) except Exception as exc: raise HTTPException(status_code=404, detail="Notification conversation not found") from exc return await _delete_conversation_comment( request, step_up_grant, repository, number, comment_id ) @app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201) async def attach_to_assigned_issue( request: Request, owner: str, repo: str, number: int = PathParam(gt=0), idempotency_key: str | None = Header(default=None, max_length=128), ): repository = f"{owner}/{repo}" try: if request.headers.get("content-type", "").lower().startswith("multipart/form-data"): form = await request.form() uploaded = form.get("file") if not isinstance(uploaded, UploadFile): raise ValueError("screenshot file is required") filename = str(uploaded.filename or "") content_type = str(uploaded.content_type or "") content = _validate_binary_attachment(filename, content_type, await uploaded.read()) else: attachment = IssueAttachment.model_validate(await request.json()) filename = attachment.filename content_type = attachment.content_type content = attachment.content() except (ValueError, ValidationError) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc async def upload_attachment(): result = await gitea_proxy.upload_assigned_issue_attachment( repository, number, filename, content_type, content, ) safe_name = ( result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") .replace("\r", " ").replace("\n", " ") ) safe_url = result["url"].replace("<", "%3C").replace(">", "%3E") result["markdown"] = f"![{safe_name}](<{safe_url}>)" return result try: result = await _run_idempotent_authored_action( upload_attachment(), idempotency_key=idempotency_key, fingerprint=( "issue-attachment", repository, number, filename, content_type, hashlib.sha256(content).hexdigest(), ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError as exc: raise HTTPException(status_code=404, detail="Assigned issue not found") from exc except HTTPException: raise except Exception: return JSONResponse( {"error": "The screenshot could not be uploaded. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, status_code=201) @app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/attachments", status_code=201) async def attach_to_assigned_pull( request: Request, owner: str, repo: str, number: int = PathParam(gt=0), idempotency_key: str | None = Header(default=None, max_length=128), ): repository = f"{owner}/{repo}" try: if request.headers.get("content-type", "").lower().startswith("multipart/form-data"): form = await request.form() uploaded = form.get("file") if not isinstance(uploaded, UploadFile): raise ValueError("screenshot file is required") filename = str(uploaded.filename or "") content_type = str(uploaded.content_type or "") content = _validate_binary_attachment(filename, content_type, await uploaded.read()) else: attachment = IssueAttachment.model_validate(await request.json()) filename = attachment.filename content_type = attachment.content_type content = attachment.content() except (ValueError, ValidationError) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc async def upload_attachment(): result = await gitea_proxy.upload_assigned_pull_attachment( repository, number, filename, content_type, content ) safe_name = ( result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") .replace("\r", " ").replace("\n", " ") ) safe_url = result["url"].replace("<", "%3C").replace(">", "%3E") result["markdown"] = f"![{safe_name}](<{safe_url}>)" return result try: result = await _run_idempotent_authored_action( upload_attachment(), idempotency_key=idempotency_key, fingerprint=( "pull-attachment", repository, number, filename, content_type, hashlib.sha256(content).hexdigest(), ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError as exc: raise HTTPException(status_code=404, detail="Assigned pull request not found") from exc except HTTPException: raise except Exception: return JSONResponse( {"error": "The screenshot could not be uploaded. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, status_code=201) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close") async def close_assigned_issue( request: Request, owner: str, repo: str, number: int = PathParam(gt=0), step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): repository = f"{owner}/{repo}" await _require_step_up( request, step_up_grant, action="close_issue", target=f"{repository}#{number}", ) target = f"{repository}#{number}" try: assigned = await asyncio.wait_for( gitea_proxy.is_assigned_issue(repository, number), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) if not assigned: raise HTTPException(status_code=404, detail="Assigned issue not found") journal = _security_event_store() operation_id = await asyncio.to_thread( journal.reserve, "issue_closed", target=target, ) except HTTPException: raise except Exception: return JSONResponse( {"error": "The issue could not be closed. It remains in My Work; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) try: result = await asyncio.wait_for( gitea_proxy.close_issue(repository, number), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except Exception: try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass return JSONResponse( {"error": "The issue could not be closed. It remains in My Work; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: # The close result is authoritative; the pending reservation preserves # evidence without telling the operator the issue remains open. pass return result @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels") async def assigned_issue_label_options( owner: str, repo: str, number: int = PathParam(gt=0), ): repository = f"{owner}/{repo}" async def load_labels(): if not await gitea_proxy.is_assigned_issue(repository, number): raise HTTPException(status_code=404, detail="Assigned issue not found") return await gitea_proxy.repo_labels(repository) try: return await asyncio.wait_for(load_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS) except HTTPException: raise except Exception: return JSONResponse( {"error": "Labels could not be loaded. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/labels") async def update_assigned_issue_labels( update: IssueLabelUpdate, owner: str, repo: str, number: int = PathParam(gt=0), ): repository = f"{owner}/{repo}" async def update_labels(): if not await gitea_proxy.is_assigned_issue(repository, number): raise HTTPException(status_code=404, detail="Assigned issue not found") available = await gitea_proxy.repo_labels(repository) valid_ids = { item.get("id") for item in available if isinstance(item, dict) and isinstance(item.get("id"), int) } if any(label_id not in valid_ids for label_id in update.label_ids): raise HTTPException(status_code=422, detail="Unknown repository label") return await gitea_proxy.update_issue_labels(repository, number, update.label_ids) try: return await asyncio.wait_for(update_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS) except HTTPException: raise except Exception: return JSONResponse( {"error": "Labels could not be updated. Your selection is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review") async def review_detail(owner: str, repo: str, number: int): async def load_requested_review(): repository = f"{owner}/{repo}" if not await is_requested_review(repository, number): raise HTTPException(status_code=404, detail="Review request not found") return await pull_review_detail(repository, number) try: return await asyncio.wait_for( load_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS ) except HTTPException: raise except TimeoutError: return JSONResponse( {"error": "Pull request review details timed out. Please retry."}, status_code=503, headers={ "Retry-After": str( max(1, math.ceil(REVIEW_DETAIL_TIMEOUT_SECONDS)) ) }, ) except Exception: return JSONResponse( {"error": "Pull request review details are temporarily unavailable"}, status_code=503, ) @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review/checks") async def requested_review_checks( owner: str, repo: str, number: int = PathParam(gt=0) ): repository = f"{owner}/{repo}" async def load_checks(): if not await is_requested_review(repository, number): raise HTTPException(status_code=404, detail="Review request not found") return await gitea_proxy.pull_check_status(repository, number) try: return await asyncio.wait_for( load_checks(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS ) except HTTPException: raise except TimeoutError: return JSONResponse( {"error": "Refreshing review checks timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "Review checks are temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/detail") async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt=0)): repository = f"{owner}/{repo}" async def load_assigned_pull(): if not await gitea_proxy.is_assigned_pull(repository, number): raise HTTPException(status_code=404, detail="Assigned pull request not found") return await gitea_proxy.pull_completion_detail(repository, number) try: return await asyncio.wait_for( load_assigned_pull(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS ) except HTTPException: raise except TimeoutError: return JSONResponse( {"error": "Loading the pull request timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "The pull request is temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data") async def assigned_pull_review_data( owner: str, repo: str, number: int = PathParam(gt=0) ): repository = f"{owner}/{repo}" async def load_assigned_pull_review(): if not await gitea_proxy.is_assigned_pull(repository, number): raise HTTPException(status_code=404, detail="Assigned pull request not found") return await gitea_proxy.pull_completion_review(repository, number) try: return await asyncio.wait_for( load_assigned_pull_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS ) except HTTPException: raise except TimeoutError: return JSONResponse( {"error": "Loading review and merge data timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "Review and merge data is temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/checks") async def assigned_pull_checks( owner: str, repo: str, number: int = PathParam(gt=0) ): repository = f"{owner}/{repo}" async def load_checks(): if not await gitea_proxy.is_assigned_pull(repository, number): raise HTTPException(status_code=404, detail="Assigned pull request not found") return await gitea_proxy.pull_check_status(repository, number) try: return await asyncio.wait_for( load_checks(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS ) except HTTPException: raise except TimeoutError: return JSONResponse( {"error": "Refreshing pull request checks timed out. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) except Exception: return JSONResponse( {"error": "Pull request checks are temporarily unavailable. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff-candidates") async def pull_handoff_candidates( owner: str, repo: str, number: int = PathParam(gt=0) ) -> JSONResponse: repository = f"{owner}/{repo}" try: if not await gitea_proxy.is_assigned_pull(repository, number): raise HTTPException(status_code=404, detail="Assigned pull request not found") result = await asyncio.wait_for( gitea_proxy.pull_handoff_candidates(repository), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except HTTPException: raise except Exception: return JSONResponse( {"error": "Teammates could not be loaded. Please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, headers={"Cache-Control": "no-store"}) @app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff") async def handoff_assigned_pull( handoff: IssueHandoff, owner: str, repo: str, number: int = PathParam(gt=0), ) -> JSONResponse: try: result = await asyncio.wait_for( gitea_proxy.handoff_assigned_pull( f"{owner}/{repo}", number, handoff.recipient ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError: return JSONResponse( {"error": "The pull request or recipient changed. Reload before handing off."}, status_code=409, ) except Exception: return JSONResponse( {"error": "The handoff could not be confirmed. It remains in My Work; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/release") async def release_assigned_pull( owner: str, repo: str, number: int = PathParam(gt=0) ) -> JSONResponse: try: result = await asyncio.wait_for( gitea_proxy.release_assigned_pull(f"{owner}/{repo}", number), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except gitea_proxy.IssueNotAvailableError: raise HTTPException(status_code=404, detail="Assigned pull request not found") except Exception: return JSONResponse( {"error": "The assignment could not be released. It remains in My Work; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result) @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments") async def assigned_pull_conversation( owner: str, repo: str, number: int = PathParam(gt=0), page: int | None = Query(default=None, ge=1, le=100), limit: int = Query(default=20, ge=1, le=50), ): repository = f"{owner}/{repo}" async def load_conversation(): if not await gitea_proxy.is_assigned_pull(repository, number): raise HTTPException(status_code=404, detail="Assigned pull request not found") return await gitea_proxy.issue_conversation_page(repository, number, page, limit) try: return await asyncio.wait_for( load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS ) except HTTPException: raise except Exception: return JSONResponse( {"error": "The conversation could not be loaded. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) @app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", status_code=201) async def comment_on_assigned_pull( comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0), idempotency_key: str | None = Header(default=None, max_length=128), ): repository = f"{owner}/{repo}" async def post_comment(): if not await gitea_proxy.is_assigned_pull(repository, number): raise HTTPException(status_code=404, detail="Assigned pull request not found") return await gitea_proxy.comment_on_issue(repository, number, comment.body) try: result = await _run_idempotent_authored_action( post_comment(), idempotency_key=idempotency_key, fingerprint=("pull-comment", repository, number, comment.body), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except HTTPException: raise except Exception: return JSONResponse( {"error": "The comment could not be posted. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, status_code=201) @app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge") async def merge_assigned_pull( submission: PullMergeSubmission, request: Request, owner: str, repo: str, number: int = PathParam(gt=0), step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): repository = f"{owner}/{repo}" await _require_step_up( request, step_up_grant, action="merge_pull", target=f"{repository}#{number}", ) async def merge_pull(): if not await gitea_proxy.is_assigned_pull(repository, number): raise HTTPException(status_code=404, detail="Assigned pull request not found") return await gitea_proxy.merge_assigned_pull( repository, number, submission.expected_head_sha ) try: result = await asyncio.wait_for( merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS ) await asyncio.to_thread( _security_event_store().record, "pull_merged", target=f"{repository}#{number}", ) return result except HTTPException: raise except gitea_proxy.StalePullError: return JSONResponse( {"error": "New commits were pushed. Refresh before merging."}, status_code=409, ) except gitea_proxy.PullNotMergeableError: return JSONResponse( {"error": "This pull request is not currently safe to merge. Refresh its status."}, status_code=409, ) except Exception: try: merged = await asyncio.wait_for( gitea_proxy.is_pull_merged_at_head( repository, number, submission.expected_head_sha ), timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) except Exception: merged = False if merged: return {"number": number, "merged": True, "state": "closed"} return JSONResponse( { "number": number, "merged": False, "state": "unknown", "confirmation_pending": True, "error": "Merge confirmation is pending. Check its status before retrying.", }, status_code=202, headers={"Retry-After": "1"}, ) @app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201) async def submit_review( submission: PullReviewSubmission, request: Request, owner: str, repo: str, number: int, idempotency_key: str | None = Header(default=None, max_length=128), step_up_grant: str | None = Header( default=None, alias="X-Step-Up-Grant", max_length=128 ), ): repository = f"{owner}/{repo}" if submission.decision != "comment": await _require_step_up( request, step_up_grant, action="submit_pull_review", target=( f"{repository}#{number}@{submission.expected_head_sha}:" f"{submission.decision}" ), ) async def submit_requested_review(): if not await is_requested_review(repository, number): raise HTTPException(status_code=404, detail="Review request not found") args = ( repository, number, submission.expected_head_sha, submission.decision, submission.body, ) journal = None operation_id = None if submission.decision != "comment": journal = _security_event_store() try: operation_id = await asyncio.to_thread( journal.reserve, ( "pull_review_approved" if submission.decision == "approve" else "pull_review_changes_requested" ), target=f"{repository}#{number}", ) except SecurityEventStoreError: raise HTTPException( status_code=503, detail="Security activity is temporarily unavailable", ) try: if submission.comments: result = await gitea_proxy.submit_pull_review( *args, [comment.model_dump() for comment in submission.comments], ) else: result = await gitea_proxy.submit_pull_review(*args) except ( HTTPException, gitea_proxy.StaleReviewError, gitea_proxy.InvalidReviewCommentError, ): if journal is not None and operation_id is not None: try: await asyncio.to_thread(journal.discard, operation_id) except SecurityEventStoreError: pass raise if journal is not None and operation_id is not None: try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: pass return result try: comment_fingerprint = tuple( (comment.path, comment.body, comment.new_position, comment.old_position) for comment in submission.comments ) result = await _run_idempotent_authored_action( submit_requested_review(), idempotency_key=idempotency_key, fingerprint=( "pull-review", repository, number, submission.expected_head_sha, submission.decision, submission.body, comment_fingerprint, ), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS, ) except HTTPException: raise except gitea_proxy.StaleReviewError: return JSONResponse( {"error": "New commits were pushed. Refresh the review before submitting."}, status_code=409, ) except gitea_proxy.InvalidReviewCommentError: return JSONResponse( {"error": "An inline comment no longer matches this pull request. Refresh the review."}, status_code=422, ) except Exception: return JSONResponse( {"error": "The review could not be submitted. Your draft is safe; please retry."}, status_code=503, headers={"Retry-After": "1"}, ) return JSONResponse(result, status_code=201)