|
| 1 | +"""Backend abstraction for rule-draft submission. |
| 2 | +
|
| 3 | +The Osprey engine doesn't care where rules live. Different deployments use |
| 4 | +different hosting: GitHub or Enterprise, GitLab, Tangled, an internal Gerrit, |
| 5 | +or a filesystem on a shared volume. Each is one implementation of the |
| 6 | +RuleSubmissionBackend Protocol below. |
| 7 | +
|
| 8 | +`load_backend()` reads `OSPREY_RULES_SUBMISSION_BACKEND` and instantiates the |
| 9 | +chosen backend with its own env vars. Defaults to `null` so an unconfigured |
| 10 | +install ships safe; adopters opt into a backend explicitly. |
| 11 | +
|
| 12 | +Adopter docs (env vars per backend, how to choose one): see |
| 13 | +`docs/user/manage.md`. |
| 14 | +
|
| 15 | +Adding a new backend: implement a class with `submit_draft` and |
| 16 | +`list_pending_drafts` matching the Protocol below, add a case in |
| 17 | +`load_backend()`, and update the "unknown backend" error message here plus |
| 18 | +the "no backend configured" message in `_rule_drafts_null.py`. The existing |
| 19 | +`_rule_drafts_github.py` module is a working template for HTTP-backed |
| 20 | +adapters; `_rule_drafts_local.py` for filesystem. |
| 21 | +""" |
| 22 | + |
| 23 | +from __future__ import annotations |
| 24 | + |
| 25 | +import os |
| 26 | +from dataclasses import dataclass, field |
| 27 | +from typing import Any, Protocol |
| 28 | + |
| 29 | + |
| 30 | +class RuleDraftBackendError(Exception): |
| 31 | + """Raised by any backend method when the operation cannot complete.""" |
| 32 | + |
| 33 | + def __init__(self, message: str, status_code: int = 502): |
| 34 | + super().__init__(message) |
| 35 | + self.message = message |
| 36 | + self.status_code = status_code |
| 37 | + |
| 38 | + |
| 39 | +@dataclass(frozen=True) |
| 40 | +class SubmissionResult: |
| 41 | + """Backend-neutral submit_draft return value. |
| 42 | +
|
| 43 | + `title` and `url` are what the UI surfaces in the success banner; `extras` |
| 44 | + carries backend-specific fields (PR number, branch, etc.) for adopters |
| 45 | + whose UI variants want to render more detail. |
| 46 | + """ |
| 47 | + |
| 48 | + title: str |
| 49 | + url: str | None |
| 50 | + main_sml_updated: bool = False |
| 51 | + extras: dict[str, Any] = field(default_factory=dict) |
| 52 | + |
| 53 | + def to_json(self) -> dict[str, Any]: |
| 54 | + return { |
| 55 | + 'title': self.title, |
| 56 | + 'url': self.url, |
| 57 | + 'main_sml_updated': self.main_sml_updated, |
| 58 | + **self.extras, |
| 59 | + } |
| 60 | + |
| 61 | + |
| 62 | +@dataclass(frozen=True) |
| 63 | +class PendingDraft: |
| 64 | + """Backend-neutral entry for the pending-drafts list.""" |
| 65 | + |
| 66 | + title: str |
| 67 | + url: str |
| 68 | + author: str |
| 69 | + created_at: str |
| 70 | + touched_files: list[str] |
| 71 | + extras: dict[str, Any] = field(default_factory=dict) |
| 72 | + |
| 73 | + def to_json(self) -> dict[str, Any]: |
| 74 | + return { |
| 75 | + 'title': self.title, |
| 76 | + 'url': self.url, |
| 77 | + 'author': self.author, |
| 78 | + 'created_at': self.created_at, |
| 79 | + 'touched_files': self.touched_files, |
| 80 | + **self.extras, |
| 81 | + } |
| 82 | + |
| 83 | + |
| 84 | +class RuleSubmissionBackend(Protocol): |
| 85 | + """The contract every submission backend implements. |
| 86 | +
|
| 87 | + Implementations: |
| 88 | + - submit a draft (create whatever the backend's review unit is) |
| 89 | + - optionally wire the new rule into main.sml as part of the same submission |
| 90 | + - list whatever's currently in review |
| 91 | +
|
| 92 | + Implementations raise `RuleDraftBackendError` for any failure path. |
| 93 | + """ |
| 94 | + |
| 95 | + name: str |
| 96 | + |
| 97 | + def submit_draft( |
| 98 | + self, |
| 99 | + *, |
| 100 | + draft_path: str, |
| 101 | + sml_source: str, |
| 102 | + rule_name: str, |
| 103 | + summary: str, |
| 104 | + author_email: str, |
| 105 | + is_new_rule: bool, |
| 106 | + wire_into_main: bool, |
| 107 | + ) -> SubmissionResult: ... |
| 108 | + |
| 109 | + def list_pending_drafts(self) -> list[PendingDraft]: ... |
| 110 | + |
| 111 | + |
| 112 | +def load_backend() -> RuleSubmissionBackend: |
| 113 | + """Select and instantiate the configured backend. |
| 114 | +
|
| 115 | + `OSPREY_RULES_SUBMISSION_BACKEND` picks one of: github, local, null. |
| 116 | + Unset or empty defaults to `null`. Unknown values raise so a typo doesn't |
| 117 | + silently degrade to no-op submission. |
| 118 | + """ |
| 119 | + name = (os.environ.get('OSPREY_RULES_SUBMISSION_BACKEND') or 'null').strip().lower() |
| 120 | + |
| 121 | + # Imports are deferred to keep the Protocol module dependency-free. |
| 122 | + if name == 'null': |
| 123 | + from ._rule_drafts_null import NullBackend |
| 124 | + |
| 125 | + return NullBackend() |
| 126 | + if name == 'github': |
| 127 | + from ._rule_drafts_github import GitHubBackend |
| 128 | + |
| 129 | + return GitHubBackend.from_env() |
| 130 | + if name == 'local': |
| 131 | + from ._rule_drafts_local import LocalBackend |
| 132 | + |
| 133 | + return LocalBackend.from_env() |
| 134 | + raise RuleDraftBackendError( |
| 135 | + f'Unknown OSPREY_RULES_SUBMISSION_BACKEND {name!r}; valid values are github, local, null.', |
| 136 | + status_code=500, |
| 137 | + ) |
0 commit comments